From 35a105253ed04e1eebfd381b18683962f92c64a4 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 14 Jun 2019 17:45:12 -0400 Subject: [PATCH 1/2] Implemention of a lazy context propagation algorithm --- packages/react-reconciler/src/ReactFiber.js | 8 + .../src/ReactFiberBeginWork.js | 133 ++++++++-- .../src/ReactFiberNewContext.js | 251 +++++++++++++++++- .../ReactNewContext-test.internal.js | 136 ++++++++++ packages/react/src/ReactContext.js | 1 + packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 12 files changed, 518 insertions(+), 20 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index e3e7ffbf6d1b8..1a9b978f873ec 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -190,6 +190,10 @@ export type Fiber = {| // This is used to quickly determine if a subtree has no pending changes. childExpirationTime: ExpirationTime, + // an identifier that allows the context propagation algorithm to determine + // if this fiber has been visited during a previous propagation + propagationSigil: any, + // This is a pooled version of a Fiber. Every fiber that gets updated will // eventually have a pair. There are cases when we can clean up pairs to save // memory if we need to. @@ -275,6 +279,8 @@ function FiberNode( this.expirationTime = NoWork; this.childExpirationTime = NoWork; + this.propagationSigil = null; + this.alternate = null; if (enableProfilerTimer) { @@ -425,6 +431,7 @@ export function createWorkInProgress( workInProgress.childExpirationTime = current.childExpirationTime; workInProgress.expirationTime = current.expirationTime; + workInProgress.propagationSigil = current.propagationSigil; workInProgress.child = current.child; workInProgress.memoizedProps = current.memoizedProps; @@ -803,6 +810,7 @@ export function assignFiberPropertiesInDEV( target.lastEffect = source.lastEffect; target.expirationTime = source.expirationTime; target.childExpirationTime = source.childExpirationTime; + target.propagationSigil = source.propagationSigil; target.alternate = source.alternate; if (enableProfilerTimer) { target.actualDuration = source.actualDuration; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 600389dc32b40..8ae8c9ac048a4 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -13,6 +13,7 @@ import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; import type {SuspenseContext} from './ReactFiberSuspenseContext'; +import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; import checkPropTypes from 'prop-types/checkPropTypes'; @@ -56,6 +57,7 @@ import { enableProfilerTimer, enableSuspenseServerRenderer, enableEventAPI, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -124,9 +126,13 @@ import { import { pushProvider, propagateContextChange, + propagateContexts, readContext, prepareToReadContext, calculateChangedBits, + currentPropagationSigil, + updateFromContextDependencies, + checkContextDependencies, } from './ReactFiberNewContext'; import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; @@ -169,6 +175,8 @@ import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberWorkLoop'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; let didReceiveUpdate: boolean = false; +let uncheckedContextOnBailout: boolean = false; +let preventBailout: boolean = false; let didWarnAboutBadClass; let didWarnAboutModulePatternComponent; @@ -327,7 +335,13 @@ function updateForwardRef( ); } - if (current !== null && !didReceiveUpdate) { + if ( + current !== null && + !didReceiveUpdate && + // this check uses context dependencies from the renderWithHooks call which + // incorporates nextProps + canBailout(workInProgress, renderExpirationTime) + ) { bailoutHooks(current, workInProgress, renderExpirationTime); return bailoutOnAlreadyFinishedWork( current, @@ -434,7 +448,14 @@ function updateMemoComponent( // Default to shallow comparison let compare = Component.compare; compare = compare !== null ? compare : shallowEqual; - if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) { + if ( + compare(prevProps, nextProps) && + current.ref === workInProgress.ref && + // this bailout check uses context dependencies from the previous render + // since we have not prepareToReadContext yet and have not rendered the + // component from workInProgress yet + canBailout(workInProgress, renderExpirationTime) + ) { return bailoutOnAlreadyFinishedWork( current, workInProgress, @@ -496,6 +517,10 @@ function updateSimpleMemoComponent( if ( shallowEqual(prevProps, nextProps) && current.ref === workInProgress.ref && + // this bailout check uses context dependencies from the previous render + // since we have not prepareToReadContext yet and have not rendered the + // component from workInProgress yet + canBailout(workInProgress, renderExpirationTime) && // Prevent bailout if the implementation changed due to hot reload: (__DEV__ ? workInProgress.type === current.type : true) ) { @@ -647,7 +672,15 @@ function updateFunctionComponent( ); } - if (current !== null && !didReceiveUpdate) { + if ( + current !== null && + !didReceiveUpdate && + // this bailout is using context dependencies from the renderWithHooks call + // which incorporates nextProps. + // we may be able to skip if we ran the context check in simple memo, forwardRef + // or other updaters prior to updateFunctionComponent + canBailout(workInProgress, renderExpirationTime) + ) { bailoutHooks(current, workInProgress, renderExpirationTime); return bailoutOnAlreadyFinishedWork( current, @@ -701,6 +734,13 @@ function updateClassComponent( } else { hasContext = false; } + if (enableLazyContextPropagation) { + // class components cannot use context selectors (Yet) so we can check + // dependencies for just this fiber and if needed result in a ForceUpdate + // this most closely resembles the old context propagation behahvior + updateFromContextDependencies(workInProgress, renderExpirationTime); + uncheckedContextOnBailout = false; + } prepareToReadContext(workInProgress, renderExpirationTime); const instance = workInProgress.stateNode; @@ -783,7 +823,7 @@ function finishClassComponent( const didCaptureError = (workInProgress.effectTag & DidCapture) !== NoEffect; - if (!shouldUpdate && !didCaptureError) { + if (!shouldUpdate && !didCaptureError && !preventBailout) { // Context providers should defer to sCU for rendering if (hasContext) { invalidateContextProvider(workInProgress, Component, false); @@ -862,7 +902,6 @@ function finishClassComponent( if (hasContext) { invalidateContextProvider(workInProgress, Component, true); } - return workInProgress.child; } @@ -904,10 +943,12 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; - if (nextChildren === prevChildren) { + if (nextChildren === prevChildren && !preventBailout) { // If the state is the same as before, that's a bailout because we had // no work that expires at this time. resetHydrationState(); + // @TODO need to understand if we can have an unchecked context bailout here + uncheckedContextOnBailout = false; return bailoutOnAlreadyFinishedWork( current, workInProgress, @@ -1967,26 +2008,33 @@ function updateContextProvider( } } - pushProvider(workInProgress, newValue); + const changedBits = + oldProps !== null + ? calculateChangedBits(context, newValue, oldProps.value) + : MAX_SIGNED_31_BIT_INT; + + pushProvider(workInProgress, newValue, changedBits); if (oldProps !== null) { - const oldValue = oldProps.value; - const changedBits = calculateChangedBits(context, newValue, oldValue); if (changedBits === 0) { // No change. Bailout early if children are the same. if ( oldProps.children === newProps.children && - !hasLegacyContextChanged() + !hasLegacyContextChanged() && + !preventBailout ) { + // Providers cannot read contexts so we can declare this fiber has checked + // context dependencies without actually checking + uncheckedContextOnBailout = false; return bailoutOnAlreadyFinishedWork( current, workInProgress, renderExpirationTime, ); } - } else { + } else if (!enableLazyContextPropagation) { // The context value changed. Search for matching consumers and schedule - // them to update. + // only propagateContextValue when not using the unified propagation flag propagateContextChange( workInProgress, context, @@ -2116,6 +2164,32 @@ export function markWorkInProgressReceivedUpdate() { didReceiveUpdate = true; } +function resetBailout() { + preventBailout = false; + uncheckedContextOnBailout = true; +} + +function canBailout( + workInProgress: Fiber, + renderExpirationTime: ExpirationTime, +): boolean { + if (enableLazyContextPropagation) { + uncheckedContextOnBailout = false; + if (preventBailout) { + return false; + } + if (workInProgress.propagationSigil === currentPropagationSigil()) { + return true; + } + preventBailout = checkContextDependencies( + workInProgress, + renderExpirationTime, + ); + return !preventBailout; + } + return true; +} + function bailoutOnAlreadyFinishedWork( current: Fiber | null, workInProgress: Fiber, @@ -2123,6 +2197,14 @@ function bailoutOnAlreadyFinishedWork( ): Fiber | null { cancelWorkTimer(workInProgress); + if (enableLazyContextPropagation) { + invariant( + uncheckedContextOnBailout === false, + 'work bailed out without checking context dependencies. This error is likely caused by a bug in ' + + 'React. Please file an issue.', + ); + } + if (current !== null) { // Reuse previous context list workInProgress.contextDependencies = current.contextDependencies; @@ -2133,9 +2215,20 @@ function bailoutOnAlreadyFinishedWork( stopProfilerTimerIfRunning(workInProgress); } + if ( + enableLazyContextPropagation && + workInProgress.childExpirationTime < renderExpirationTime + ) { + // if we are otherwise going to skip children, propagate context changes + // to them first in case more work is required + let child = workInProgress.child; + if (child && child.propagationSigil !== currentPropagationSigil()) { + propagateContexts(workInProgress, renderExpirationTime); + } + } + // Check if the children have any pending work. - const childExpirationTime = workInProgress.childExpirationTime; - if (childExpirationTime < renderExpirationTime) { + if (workInProgress.childExpirationTime < renderExpirationTime) { // The children don't have any work either. We can skip them. // TODO: Once we add back resuming, we should check if the children are // a work-in-progress set. If so, we need to transfer their effects. @@ -2215,7 +2308,10 @@ function beginWork( workInProgress: Fiber, renderExpirationTime: ExpirationTime, ): Fiber | null { - const updateExpirationTime = workInProgress.expirationTime; + // on work start we assume we have not checked contexts before bailout + resetBailout(); + + let updateExpirationTime = workInProgress.expirationTime; if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { @@ -2248,7 +2344,10 @@ function beginWork( // If props or context changed, mark the fiber as having performed work. // This may be unset if the props are determined to be equal later (memo). didReceiveUpdate = true; - } else if (updateExpirationTime < renderExpirationTime) { + } else if ( + updateExpirationTime < renderExpirationTime && + canBailout(workInProgress, renderExpirationTime) + ) { didReceiveUpdate = false; // This fiber does not have any pending work. Bailout without entering // the begin phase. There's still some bookkeeping we that needs to be done @@ -2285,7 +2384,7 @@ function beginWork( break; case ContextProvider: { const newValue = workInProgress.memoizedProps.value; - pushProvider(workInProgress, newValue); + pushProvider(workInProgress, newValue, 0); break; } case Profiler: diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index c0b10fabbd5fb..bb833fe104262 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -43,7 +43,10 @@ import { } from 'react-reconciler/src/ReactUpdateQueue'; import {NoWork} from './ReactFiberExpirationTime'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; +import { + enableSuspenseServerRenderer, + enableLazyContextPropagation, +} from 'shared/ReactFeatureFlags'; const valueCursor: StackCursor = createCursor(null); @@ -82,9 +85,60 @@ export function exitDisallowedContextReadInDEV(): void { } } -export function pushProvider(providerFiber: Fiber, nextValue: T): void { +let contextSet: Set> = new Set(); +let propagationSigil = null; +let propagationHasChangedBits = false; + +export function currentPropagationSigil(): mixed { + return propagationSigil; +} + +function someChangedBits(): boolean { + let iter = contextSet.values(); + let step = iter.next(); + for (; !step.done; step = iter.next()) { + const context = step.value; + if (context._currentChangedBits > 0) { + return true; + } + } + return false; +} + +export function pushProvider( + providerFiber: Fiber, + nextValue: T, + nextChangedBits: number, +): void { const context: ReactContext = providerFiber.type._context; + if (enableLazyContextPropagation) { + // put the context in the set of contexts for use in computing whether + // changedBits exist for current suite of contexts + contextSet.add(context); + + let currentChangedBits = context._currentChangedBits; + // update propagationHasChangedBits. only do full check if nextChangedBits + // is zero and currentChangedBits is greater than zero. Otherwise can can + // infer without checking each context + let nextPropagationHasChangedBits = + nextChangedBits > 0 || + (currentChangedBits > 0 && someChangedBits()) || + propagationHasChangedBits; + + // set next changed bits on the context + push(valueCursor, currentChangedBits, providerFiber); + context._currentChangedBits = nextChangedBits; + + // set next propagationHasChangedBits + push(valueCursor, propagationHasChangedBits, providerFiber); + propagationHasChangedBits = nextPropagationHasChangedBits; + + // create a new propagationSigil and save the previous one + push(valueCursor, propagationSigil, providerFiber); + propagationSigil = {}; + } + if (isPrimaryRenderer) { push(valueCursor, context._currentValue, providerFiber); @@ -118,7 +172,6 @@ export function pushProvider(providerFiber: Fiber, nextValue: T): void { export function popProvider(providerFiber: Fiber): void { const currentValue = valueCursor.current; - pop(valueCursor, providerFiber); const context: ReactContext = providerFiber.type._context; @@ -127,6 +180,20 @@ export function popProvider(providerFiber: Fiber): void { } else { context._currentValue2 = currentValue; } + + if (enableLazyContextPropagation) { + // restore previous propagationSigil + propagationSigil = valueCursor.current; + pop(valueCursor, providerFiber); + + // restore previous propagationHasChangedBits + propagationHasChangedBits = valueCursor.current; + pop(valueCursor, providerFiber); + + // pop changedBits value + context._currentChangedBits = valueCursor.current; + pop(valueCursor, providerFiber); + } } export function calculateChangedBits( @@ -186,6 +253,184 @@ function scheduleWorkOnParentPath( } } +export function checkContextDependencies( + fiber: Fiber, + renderExpirationTime: ExpirationTime, +): boolean { + if (enableLazyContextPropagation && propagationHasChangedBits) { + const list = fiber.contextDependencies; + if (list != null) { + let dependency = list.first; + while (dependency !== null) { + // Check if dependency bits have changed for context + let context = dependency.context; + let observedBits = dependency.observedBits; + if ((observedBits & context._currentChangedBits) !== 0) { + return true; + } + dependency = dependency.next; + } + } + } + return false; +} + +export function updateFromContextDependencies( + fiber: Fiber, + renderExpirationTime: ExpirationTime, +): boolean { + if (enableLazyContextPropagation) { + let alternate = fiber.alternate; + + // mark fiber propagationSigil + fiber.propagationSigil = propagationSigil; + if (alternate !== null) { + alternate.propagationSigil = propagationSigil; + } + + let requiresUpdate = checkContextDependencies(fiber); + + if (requiresUpdate) { + if (fiber.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(fiber, update); + } + + if (fiber.expirationTime < renderExpirationTime) { + fiber.expirationTime = renderExpirationTime; + } + if ( + alternate !== null && + alternate.expirationTime < renderExpirationTime + ) { + alternate.expirationTime = renderExpirationTime; + } + + scheduleWorkOnParentPath(fiber.return, renderExpirationTime); + + // Mark the expiration time on the list, too. + const list = fiber.contextDependencies; + if (list.expirationTime < renderExpirationTime) { + list.expirationTime = renderExpirationTime; + } + + // Since we already found a match, we can stop traversing the + // dependency list. + return true; + } + } + return false; +} + +export function propagateContexts( + workInProgress: Fiber, + renderExpirationTime: ExpirationTime, +): void { + // no need to propagate if no context values have not changed + if (propagationHasChangedBits === false) { + return; + } + 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; + + let alternate = fiber.alternate; + + // Visit this fiber + let didUpdateFromContext = updateFromContextDependencies( + fiber, + renderExpirationTime, + ); + + if (didUpdateFromContext) { + // fiber required work based on it's context dependencies. do not go deeper + nextFiber = null; + } else if ( + fiber.expirationTime >= renderExpirationTime || + fiber.childExpirationTime >= renderExpirationTime + ) { + // this fiber or a descendent are already scheduled for work. + // on to siblings + nextFiber = null; + } else if (fiber.tag === ContextProvider) { + // Don't scan deeper since this is a ContextProvider + // schedule work on Provider + if (fiber.expirationTime < renderExpirationTime) { + fiber.expirationTime = renderExpirationTime; + } + if ( + alternate !== null && + alternate.expirationTime < renderExpirationTime + ) { + alternate.expirationTime = renderExpirationTime; + } + scheduleWorkOnParentPath(fiber.return, renderExpirationTime); + // don't go deeper, visit siblings if any + nextFiber = null; + } else if ( + enableSuspenseServerRenderer && + fiber.tag === DehydratedSuspenseComponent + ) { + // If a dehydrated suspense component is in this subtree, we don't know + // if it will have any context consumers in it. The best we can do is + // mark it as having updates on its children. + if (fiber.expirationTime < renderExpirationTime) { + fiber.expirationTime = renderExpirationTime; + } + if ( + alternate !== null && + alternate.expirationTime < renderExpirationTime + ) { + alternate.expirationTime = renderExpirationTime; + } + // This is intentionally passing this fiber as the parent + // because we want to schedule this fiber as having work + // on its children. We'll use the childExpirationTime on + // this fiber to indicate that a context has changed. + scheduleWorkOnParentPath(fiber, renderExpirationTime); + nextFiber = fiber.sibling; + } else { + // Traverse down. + nextFiber = fiber.child; + } + + if (nextFiber !== null) { + // Set the return pointer of the child to the work-in-progress fiber. + 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; + } + 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; + } + } + fiber = nextFiber; + } +} + export function propagateContextChange( workInProgress: Fiber, context: ReactContext, diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index e04b75dc593a1..cee0f05959bf2 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -1049,6 +1049,142 @@ describe('ReactNewContext', () => { span(2), ]); }); + describe('stress test', () => { + it('controlled lots of contexts', () => { + let ContextA = React.createContext(0); + let ConsumerA = getConsumer(ContextA); + + let ContextB = React.createContext(0); + let ConsumerB = getConsumer(ContextB); + + let ContextC = React.createContext(0); + let ConsumerC = getConsumer(ContextC); + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + const Foo = React.memo(({consumer, depth, name, children}) => { + let Consumer = consumer; + + if (typeof depth !== 'number' || depth <= 1) { + return ( + + {value => } + + ); + } else { + return ( + + + {_ => ( + + {children} + + )} + + + ); + } + }); + + const Yield = ({name, value}) => { + let output = `${name}: ${value}`; + Scheduler.yieldValue(output); + return {output}; + }; + + function App(props) { + return ( + + + + + + + + + + ); + } + + for (let i = 0; i < 25; i++) { + // each individually + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 0', 'B: 0', 'C: 0']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 1']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['B: 1']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['C: 1']); + // two at a time + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 2', 'B: 2']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['B: 3', 'C: 3']); + // all at once + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 4', 'B: 4', 'C: 4']); + } + }); + it('non-context stress test', () => { + const Foo = React.memo(({depth, name, children}) => { + if (typeof depth !== 'number' || depth <= 1) { + return ; + } else { + return ( + + {children} + + ); + } + }); + + const Yield = ({name}) => { + let output = `${name}`; + Scheduler.yieldValue(output); + return {output}; + }; + + function App(props) { + return ( +
+ + + +
+ ); + } + + for (let i = 0; i < 50; i++) { + // each individually + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + } + }); + }); }); } diff --git a/packages/react/src/ReactContext.js b/packages/react/src/ReactContext.js index 643a13019e074..d28ccf2aa438b 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -42,6 +42,7 @@ export function createContext( // Secondary renderers store their context values on separate fields. _currentValue: defaultValue, _currentValue2: defaultValue, + _currentChangedBits: 0, // Used to track how many concurrent renderers this context currently // supports within in a single renderer. Such as parallel server rendering. _threadCount: 0, diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index f33ef13e8ac54..c3476ab1b8aa5 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -75,3 +75,6 @@ export const revertPassiveEffectsChange = false; // but without making them discrete. The flag exists in case it causes // starvation problems. export const enableUserBlockingEvents = false; + +// Alternate Context Propagation algorithm, variation that propagates all contexts together +export const enableLazyContextPropagation = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 1395754de0848..84fe845f72c3e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -36,6 +36,7 @@ export const enableJSXTransformAPI = false; export const warnAboutMissingMockScheduler = true; export const revertPassiveEffectsChange = false; export const enableUserBlockingEvents = false; +export const enableLazyContextPropagation = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index cbd417aa086ab..8307a31218f8c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -31,6 +31,7 @@ export const enableJSXTransformAPI = false; export const warnAboutMissingMockScheduler = false; export const revertPassiveEffectsChange = false; export const enableUserBlockingEvents = false; +export const enableLazyContextPropagation = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index fd7086c247122..63c430f038931 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -31,6 +31,7 @@ export const enableJSXTransformAPI = false; export const warnAboutMissingMockScheduler = true; export const revertPassiveEffectsChange = false; export const enableUserBlockingEvents = false; +export const enableLazyContextPropagation = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index fc30a865ec046..c7074e5fe8f4a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -31,6 +31,7 @@ export const enableJSXTransformAPI = false; export const warnAboutMissingMockScheduler = false; export const revertPassiveEffectsChange = false; export const enableUserBlockingEvents = false; +export const enableLazyContextPropagation = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index be456ba14257d..25d2f15cddbd6 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -31,6 +31,7 @@ export const enableEventAPI = true; export const enableJSXTransformAPI = true; export const warnAboutMissingMockScheduler = true; export const enableUserBlockingEvents = false; +export const enableLazyContextPropagation = true; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index b8328ac7c57b4..89cc0090af322 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -21,6 +21,7 @@ export const { warnAboutDeprecatedSetNativeProps, revertPassiveEffectsChange, enableUserBlockingEvents, + enableLazyContextPropagation, } = require('ReactFeatureFlags'); // In www, we have experimental support for gathering data From 9b816ce096256070436bf0e042f50e81fbc049af Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 14 Jun 2019 17:47:52 -0400 Subject: [PATCH 2/2] Implementation of useContextSelector hook --- .../react-reconciler/src/ReactFiberHooks.js | 90 +++++++++- .../src/ReactFiberNewContext.js | 63 ++++++- .../ReactNewContext-test.internal.js | 159 ++++++++++++++++++ packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 29 ++++ 5 files changed, 341 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 2946c931cdc58..b7dfdbe33af93 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -17,7 +17,7 @@ import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {NoWork} from './ReactFiberExpirationTime'; -import {readContext} from './ReactFiberNewContext'; +import {readContext, selectFromContext} from './ReactFiberNewContext'; import { Update as UpdateEffect, Passive as PassiveEffect, @@ -64,6 +64,7 @@ export type Dispatcher = { context: ReactContext, observedBits: void | number | boolean, ): T, + useContextSelector(context: ReactContext, selector: (T) => S): S, useRef(initialValue: T): {current: T}, useEffect( create: () => (() => void) | void, @@ -602,6 +603,63 @@ function updateWorkInProgressHook(): Hook { return workInProgressHook; } +function makeSelect( + context: ReactContext, + selector: T => S, +): (T, (T) => S) => [S, boolean] { + // close over memoized value and selection + let previousValue, previousSelection; + + // select function will return a tuple of the selection as well as whether + // the selection was a new value or not + return function select(value: T) { + let selection = previousSelection; + let isNew = false; + + // don't recompute if values are the same + if (!is(value, previousValue)) { + selection = selector(value); + if (!is(selection, previousSelection)) { + // if same we can still consider the selection memoized since the selected values are identical + isNew = true; + } + } + previousValue = value; + previousSelection = selection; + return [selection, isNew]; + }; +} + +function mountContextSelector( + context: ReactContext, + selector: T => S, +): S { + const hook = mountWorkInProgressHook(); + let select = makeSelect(context, selector); + let [selection] = selectFromContext(context, select); + hook.memoizedState = [context, selector, select]; + return selection; +} + +function updateContextSelector( + context: ReactContext, + selector: T => S, +): S { + const hook = updateWorkInProgressHook(); + let [previousContext, previousSelector, previousSelect] = hook.memoizedState; + + if (context !== previousContext || selector !== previousSelector) { + // context and or selector have changed. we need to discard memoizedState + // and recreate our select function + let select = makeSelect(context, selector); + let [selection] = selectFromContext(context, select); + hook.memoizedState = [context, selector, select]; + return selection; + } else { + return selectFromContext(context, previousSelect)[0]; + } +} + function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { return { lastEffect: null, @@ -1223,6 +1281,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, + useContextSelector: throwInvalidHookError, useEffect: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -1238,6 +1297,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, + useContextSelector: mountContextSelector, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -1253,6 +1313,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, + useContextSelector: updateContextSelector, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -1312,6 +1373,11 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useContextSelector(context: ReactContext, selector: T => S): S { + currentHookNameInDev = 'useContextSelector'; + mountHookTypesDev(); + return mountContextSelector(context, selector); + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -1413,6 +1479,11 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useContextSelector(context: ReactContext, selector: T => S): S { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + return mountContextSelector(context, selector); + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -1510,6 +1581,11 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useContextSelector(context: ReactContext, selector: T => S): S { + currentHookNameInDev = 'useContextSelector'; + updateHookTypesDev(); + return updateContextSelector(context, selector); + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -1610,6 +1686,12 @@ if (__DEV__) { mountHookTypesDev(); return readContext(context, observedBits); }, + useContextSelector(context: ReactContext, selector: T => S): S { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountContextSelector(context, selector); + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -1718,6 +1800,12 @@ if (__DEV__) { updateHookTypesDev(); return readContext(context, observedBits); }, + useContextSelector(context: ReactContext, selector: T => S): S { + currentHookNameInDev = 'useContextSelector'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateContextSelector(context, selector); + }, useEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index bb833fe104262..f98ab4d020bc0 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -266,7 +266,21 @@ export function checkContextDependencies( let context = dependency.context; let observedBits = dependency.observedBits; if ((observedBits & context._currentChangedBits) !== 0) { - return true; + let requiresUpdate = true; + + let selector = dependency.selector; + if (typeof selector === 'function') { + let [, isNew] = selector( + isPrimaryRenderer + ? context._currentValue + : context._currentValue2, + ); + requiresUpdate = isNew; + } + + if (requiresUpdate) { + return true; + } } dependency = dependency.next; } @@ -634,3 +648,50 @@ export function readContext( } return isPrimaryRenderer ? context._currentValue : context._currentValue2; } + +export function selectFromContext( + context: ReactContext, + select: T => [S, boolean], +): [S, boolean] { + if (__DEV__) { + // This warning would fire if you read context inside a Hook like useMemo. + // Unlike the class check below, it's not enforced in production for perf. + warning( + !isDisallowedContextReadInDEV, + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + } + + let contextItem = { + context: ((context: any): ReactContext), + observedBits: MAX_SIGNED_31_BIT_INT, + selector: select, + next: null, + }; + + if (lastContextDependency === null) { + invariant( + currentlyRenderingFiber !== null, + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + + // This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + currentlyRenderingFiber.contextDependencies = { + first: contextItem, + expirationTime: NoWork, + }; + } else { + // Append a new context item. + lastContextDependency = lastContextDependency.next = contextItem; + } + return isPrimaryRenderer + ? select(context._currentValue) + : select(context._currentValue2); +} diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index cee0f05959bf2..2091dcf8d71f2 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -1355,6 +1355,165 @@ describe('ReactNewContext', () => { }); }); + describe('useContextSelector', () => { + it('context propagation defers checks as long as possible', () => { + const Context = React.createContext('abcdefg'); + + let lastSelector; + + let i = 0; + + let makeSelector = () => { + lastSelector = (j => v => { + Scheduler.yieldValue('selector' + j); + return v; + })(i++); + return lastSelector; + }; + + makeSelector(); + + let Foo = React.memo(function Foo({selector}) { + Scheduler.yieldValue('Foo'); + let selection = React.useContextSelector(Context, selector); + return {selection}; + }); + + let App = ({value, selector}) => { + return ( + +
+ +
+
+ ); + }; + + // initial render + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo', 'selector1']); + + // different selector -> Foo should do memo check and take new selector and then update + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo', 'selector2']); + + // shallow equal props -> memo should bailout, no selector was called but memoized so no yield + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + + // differe context value, memo props shallow equal + // -> call selector before attempted bailout, end up updating instead of bailout + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['selector2', 'Foo']); + }); + it('general test', () => { + const Context = React.createContext('abcdefg'); + const FooContext = React.createContext(0); + const BarContext = React.createContext(0); + + function Provider(props) { + return ( + + {props.children} + + ); + } + + function Foo(props) { + let index = React.useContext(FooContext); + let selector = React.useCallback(v => v.substring(0, index), [index]); + let selection = React.useContextSelector(Context, selector); + Scheduler.yieldValue('Foo'); + return ; + } + + function Bar(props) { + let index = React.useContext(BarContext); + let selector = React.useCallback(v => v.substring(index), [index]); + let selection = React.useContextSelector(Context, selector); + Scheduler.yieldValue('Bar'); + return ; + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( + + + + + + + + + + + + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo', 'Bar']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: ab'), + span('bar selection: cdefg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: abc'), + span('bar selection: cdefg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: a*c'), + span('bar selection: cdefg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Bar']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: a*c'), + span('bar selection: *cdefg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo', 'Bar']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: a|c'), + span('bar selection: |cdefg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Bar']); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: a|c'), + span('bar selection: efg'), + ]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([ + span('foo selection: a|c'), + span('bar selection: efg'), + ]); + }); + }); + describe('Context.Consumer', () => { it('warns if child is not a function', () => { spyOnDev(console, 'error'); diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 7196344adef8e..b94c7f9e42aaf 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -31,6 +31,7 @@ import memo from './memo'; import { useCallback, useContext, + useContextSelector, useEffect, useImperativeHandle, useDebugValue, @@ -75,6 +76,7 @@ const React = { useCallback, useContext, + useContextSelector, useEffect, useImperativeHandle, useDebugValue, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 9c325b32a4321..65690bf3c2bf0 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -69,6 +69,35 @@ export function useContext( return dispatcher.useContext(Context, unstable_observedBits); } +export function useContextSelector( + Context: ReactContext, + selector: T => S, +) { + const dispatcher = resolveDispatcher(); + if (__DEV__) { + // TODO: add a more generic warning for invalid values. + if ((Context: any)._context !== undefined) { + const realContext = (Context: any)._context; + // Don't deduplicate because this legitimately causes bugs + // and nobody should be using this in existing code. + if (realContext.Consumer === Context) { + warning( + false, + 'Calling useContextSelector(Context.Consumer, selector) is not supported, may cause bugs, and will be ' + + 'removed in a future major release. Did you mean to call useContextSelector(Context, selector) instead?', + ); + } else if (realContext.Provider === Context) { + warning( + false, + 'Calling useContext(Context.Provider, selector) is not supported. ' + + 'Did you mean to call useContext(Contextm, selector) instead?', + ); + } + } + } + return dispatcher.useContextSelector(Context, selector); +} + export function useState(initialState: (() => S) | S) { const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState);