diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 66584784ee379..345f668fc62e8 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -81,6 +81,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableCache, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -154,6 +155,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.new'; import { pushProvider, propagateContextChange, + lazilyPropagateParentContextChanges, + propagateParentContextChangesToDeferredTree, + checkIfContextChanged, readContext, prepareToReadContext, calculateChangedBits, @@ -646,6 +650,18 @@ function updateOffscreenComponent( // We're about to bail out, but we need to push this to the stack anyway // to avoid a push/pop misalignment. pushRenderLanes(workInProgress, nextBaseLanes); + + if (enableLazyContextPropagation && current !== null) { + // Since this tree will resume rendering in a separate render, we need + // to propagate parent contexts now so we don't lose track of which + // ones changed. + propagateParentContextChangesToDeferredTree( + current, + workInProgress, + renderLanes, + ); + } + return null; } else { // This is the second render. The surrounding visible content has already @@ -2445,6 +2461,19 @@ function updateDehydratedSuspenseComponent( renderLanes, ); } + + if ( + enableLazyContextPropagation && + // TODO: Factoring is a little weird, since we check this right below, too. + // But don't want to re-arrange the if-else chain until/unless this + // feature lands. + !didReceiveUpdate + ) { + // We need to check if any children have context before we decide to bail + // out, so propagate the changes now. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + } + // We use lanes to indicate that a child might depend on context, so if // any context has changed, we need to treat is as if the input might have changed. const hasContextChanged = includesSomeLane(renderLanes, current.childLanes); @@ -2970,25 +2999,37 @@ function updateContextProvider( pushProvider(workInProgress, context, newValue); - 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() - ) { - return bailoutOnAlreadyFinishedWork( - current, + if (enableLazyContextPropagation) { + // In the lazy propagation implementation, we don't scan for matching + // consumers until something bails out, because until something bails out + // we're going to visit those nodes, anyway. The trade-off is that it shifts + // responsibility to the consumer to track whether something has changed. + } else { + 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() + ) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes, + ); + } + } else { + // The context value changed. Search for matching consumers and schedule + // them to update. + propagateContextChange( workInProgress, + context, + changedBits, renderLanes, ); } - } else { - // The context value changed. Search for matching consumers and schedule - // them to update. - propagateContextChange(workInProgress, context, changedBits, renderLanes); } } @@ -3100,13 +3141,23 @@ function bailoutOnAlreadyFinishedWork( // 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. - return null; - } else { - // This fiber doesn't have work, but its subtree does. Clone the child - // fibers and continue. - cloneChildFibers(current, workInProgress); - return workInProgress.child; + + if (enableLazyContextPropagation && current !== null) { + // Before bailing out, check if there are any context changes in + // the children. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + if (!includesSomeLane(renderLanes, workInProgress.childLanes)) { + return null; + } + } else { + return null; + } } + + // This fiber doesn't have work, but its subtree does. Clone the child + // fibers and continue. + cloneChildFibers(current, workInProgress); + return workInProgress.child; } function remountFiber( @@ -3175,7 +3226,7 @@ function beginWork( workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { - const updateLanes = workInProgress.lanes; + let updateLanes = workInProgress.lanes; if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { @@ -3196,6 +3247,17 @@ function beginWork( } if (current !== null) { + // TODO: The factoring of this block is weird. + if ( + enableLazyContextPropagation && + !includesSomeLane(renderLanes, updateLanes) + ) { + const dependencies = current.dependencies; + if (dependencies !== null && checkIfContextChanged(dependencies)) { + updateLanes = mergeLanes(updateLanes, renderLanes); + } + } + const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; @@ -3316,6 +3378,9 @@ function beginWork( // primary children and work on the fallback. return child.sibling; } else { + // Note: We can return `null` here because we already checked + // whether there were nested context consumers, via the call to + // `bailoutOnAlreadyFinishedWork` above. return null; } } @@ -3330,11 +3395,30 @@ function beginWork( case SuspenseListComponent: { const didSuspendBefore = (current.flags & DidCapture) !== NoFlags; - const hasChildWork = includesSomeLane( + let hasChildWork = includesSomeLane( renderLanes, workInProgress.childLanes, ); + if (enableLazyContextPropagation && !hasChildWork) { + // Context changes may not have been propagated yet. We need to do + // that now, before we can decide whether to bail out. + // TODO: We use `childLanes` as a heuristic for whether there is + // remaining work in a few places, including + // `bailoutOnAlreadyFinishedWork` and + // `updateDehydratedSuspenseComponent`. We should maybe extract this + // into a dedicated function. + lazilyPropagateParentContextChanges( + current, + workInProgress, + renderLanes, + ); + hasChildWork = includesSomeLane( + renderLanes, + workInProgress.childLanes, + ); + } + if (didSuspendBefore) { if (hasChildWork) { // If something was in fallback state last time, and we have all the diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 8faa4d859b7e0..72b4e33431b00 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -81,6 +81,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableCache, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -154,6 +155,9 @@ import {findFirstSuspended} from './ReactFiberSuspenseComponent.old'; import { pushProvider, propagateContextChange, + lazilyPropagateParentContextChanges, + propagateParentContextChangesToDeferredTree, + checkIfContextChanged, readContext, prepareToReadContext, calculateChangedBits, @@ -646,6 +650,18 @@ function updateOffscreenComponent( // We're about to bail out, but we need to push this to the stack anyway // to avoid a push/pop misalignment. pushRenderLanes(workInProgress, nextBaseLanes); + + if (enableLazyContextPropagation && current !== null) { + // Since this tree will resume rendering in a separate render, we need + // to propagate parent contexts now so we don't lose track of which + // ones changed. + propagateParentContextChangesToDeferredTree( + current, + workInProgress, + renderLanes, + ); + } + return null; } else { // This is the second render. The surrounding visible content has already @@ -2445,6 +2461,19 @@ function updateDehydratedSuspenseComponent( renderLanes, ); } + + if ( + enableLazyContextPropagation && + // TODO: Factoring is a little weird, since we check this right below, too. + // But don't want to re-arrange the if-else chain until/unless this + // feature lands. + !didReceiveUpdate + ) { + // We need to check if any children have context before we decide to bail + // out, so propagate the changes now. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + } + // We use lanes to indicate that a child might depend on context, so if // any context has changed, we need to treat is as if the input might have changed. const hasContextChanged = includesSomeLane(renderLanes, current.childLanes); @@ -2970,25 +2999,37 @@ function updateContextProvider( pushProvider(workInProgress, context, newValue); - 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() - ) { - return bailoutOnAlreadyFinishedWork( - current, + if (enableLazyContextPropagation) { + // In the lazy propagation implementation, we don't scan for matching + // consumers until something bails out, because until something bails out + // we're going to visit those nodes, anyway. The trade-off is that it shifts + // responsibility to the consumer to track whether something has changed. + } else { + 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() + ) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes, + ); + } + } else { + // The context value changed. Search for matching consumers and schedule + // them to update. + propagateContextChange( workInProgress, + context, + changedBits, renderLanes, ); } - } else { - // The context value changed. Search for matching consumers and schedule - // them to update. - propagateContextChange(workInProgress, context, changedBits, renderLanes); } } @@ -3100,13 +3141,23 @@ function bailoutOnAlreadyFinishedWork( // 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. - return null; - } else { - // This fiber doesn't have work, but its subtree does. Clone the child - // fibers and continue. - cloneChildFibers(current, workInProgress); - return workInProgress.child; + + if (enableLazyContextPropagation && current !== null) { + // Before bailing out, check if there are any context changes in + // the children. + lazilyPropagateParentContextChanges(current, workInProgress, renderLanes); + if (!includesSomeLane(renderLanes, workInProgress.childLanes)) { + return null; + } + } else { + return null; + } } + + // This fiber doesn't have work, but its subtree does. Clone the child + // fibers and continue. + cloneChildFibers(current, workInProgress); + return workInProgress.child; } function remountFiber( @@ -3175,7 +3226,7 @@ function beginWork( workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { - const updateLanes = workInProgress.lanes; + let updateLanes = workInProgress.lanes; if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { @@ -3196,6 +3247,17 @@ function beginWork( } if (current !== null) { + // TODO: The factoring of this block is weird. + if ( + enableLazyContextPropagation && + !includesSomeLane(renderLanes, updateLanes) + ) { + const dependencies = current.dependencies; + if (dependencies !== null && checkIfContextChanged(dependencies)) { + updateLanes = mergeLanes(updateLanes, renderLanes); + } + } + const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; @@ -3316,6 +3378,9 @@ function beginWork( // primary children and work on the fallback. return child.sibling; } else { + // Note: We can return `null` here because we already checked + // whether there were nested context consumers, via the call to + // `bailoutOnAlreadyFinishedWork` above. return null; } } @@ -3330,11 +3395,30 @@ function beginWork( case SuspenseListComponent: { const didSuspendBefore = (current.flags & DidCapture) !== NoFlags; - const hasChildWork = includesSomeLane( + let hasChildWork = includesSomeLane( renderLanes, workInProgress.childLanes, ); + if (enableLazyContextPropagation && !hasChildWork) { + // Context changes may not have been propagated yet. We need to do + // that now, before we can decide whether to bail out. + // TODO: We use `childLanes` as a heuristic for whether there is + // remaining work in a few places, including + // `bailoutOnAlreadyFinishedWork` and + // `updateDehydratedSuspenseComponent`. We should maybe extract this + // into a dedicated function. + lazilyPropagateParentContextChanges( + current, + workInProgress, + renderLanes, + ); + hasChildWork = includesSomeLane( + renderLanes, + workInProgress.childLanes, + ); + } + if (didSuspendBefore) { if (hasChildWork) { // If something was in fallback state last time, and we have all the diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index f29a1b645f15a..61abbb6122ed4 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,49 +12,51 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b00000000000000000000; -export const PerformedWork = /* */ 0b00000000000000000001; +export const NoFlags = /* */ 0b0000000000000000000000; +export const PerformedWork = /* */ 0b0000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b00000000000000000010; -export const Update = /* */ 0b00000000000000000100; +export const Placement = /* */ 0b0000000000000000000010; +export const Update = /* */ 0b0000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b00000000000000001000; -export const ChildDeletion = /* */ 0b00000000000000010000; -export const ContentReset = /* */ 0b00000000000000100000; -export const Callback = /* */ 0b00000000000001000000; -export const DidCapture = /* */ 0b00000000000010000000; -export const Ref = /* */ 0b00000000000100000000; -export const Snapshot = /* */ 0b00000000001000000000; -export const Passive = /* */ 0b00000000010000000000; -export const Hydrating = /* */ 0b00000000100000000000; +export const Deletion = /* */ 0b0000000000000000001000; +export const ChildDeletion = /* */ 0b0000000000000000010000; +export const ContentReset = /* */ 0b0000000000000000100000; +export const Callback = /* */ 0b0000000000000001000000; +export const DidCapture = /* */ 0b0000000000000010000000; +export const Ref = /* */ 0b0000000000000100000000; +export const Snapshot = /* */ 0b0000000000001000000000; +export const Passive = /* */ 0b0000000000010000000000; +export const Hydrating = /* */ 0b0000000000100000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b00000001000000000000; +export const Visibility = /* */ 0b0000000001000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b00000001111111111111; +export const HostEffectMask = /* */ 0b0000000001111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b00000010000000000000; -export const ShouldCapture = /* */ 0b00000100000000000000; +export const Incomplete = /* */ 0b0000000010000000000000; +export const ShouldCapture = /* */ 0b0000000100000000000000; // TODO (effects) Remove this bit once the new reconciler is synced to the old. -export const PassiveUnmountPendingDev = /* */ 0b00001000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b00010000000000000000; +export const PassiveUnmountPendingDev = /* */ 0b0000001000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b0000010000000000000000; +export const DidPropagateContext = /* */ 0b0000100000000000000000; +export const NeedsPropagation = /* */ 0b0001000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const PassiveStatic = /* */ 0b00100000000000000000; +export const PassiveStatic = /* */ 0b0010000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b01000000000000000000; -export const MountPassiveDev = /* */ 0b10000000000000000000; +export const MountLayoutDev = /* */ 0b0100000000000000000000; +export const MountPassiveDev = /* */ 0b1000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberNewContext.new.js b/packages/react-reconciler/src/ReactFiberNewContext.new.js index 91ba7c9ff6516..29a1eeef8cdd9 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; import type { Fiber, ContextDependency, @@ -33,6 +33,11 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.new'; +import { + NoFlags, + DidPropagateContext, + NeedsPropagation, +} from './ReactFiberFlags'; import invariant from 'shared/invariant'; import is from 'shared/objectIs'; @@ -195,6 +200,37 @@ export function propagateContextChange( changedBits: number, renderLanes: Lanes, ): void { + if (enableLazyContextPropagation) { + // TODO: This path is only used by Cache components. Update + // lazilyPropagateParentContextChanges to look for Cache components so they + // can take advantage of lazy propagation. + const forcePropagateEntireTree = true; + propagateContextChanges( + workInProgress, + [context, changedBits], + renderLanes, + forcePropagateEntireTree, + ); + } else { + propagateContextChange_eager( + workInProgress, + context, + changedBits, + renderLanes, + ); + } +} + +function propagateContextChange_eager( + workInProgress: Fiber, + context: ReactContext, + changedBits: number, + renderLanes: Lanes, +): void { + // Only used by eager implemenation + if (enableLazyContextPropagation) { + return; + } let fiber = workInProgress.child; if (fiber !== null) { // Set the return pointer of the child to the work-in-progress fiber. @@ -216,45 +252,32 @@ export function propagateContextChange( (dependency.observedBits & changedBits) !== 0 ) { // Match! Schedule an update on this fiber. - - if (enableLazyContextPropagation) { - // In the lazy implemenation, don't mark a dirty flag on the - // dependency itself. Not all changes are propagated, so we can't - // rely on the propagation function alone to determine whether - // something has changed; the consumer will check. In the future, - // we could add back a dirty flag as an optimization to avoid - // double checking, but until we have selectors it's not really - // worth the trouble. - } else { - if (fiber.tag === ClassComponent) { - // Schedule a force update on the work-in-progress. - const lane = pickArbitraryLane(renderLanes); - const update = createUpdate(NoTimestamp, lane); - 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. - - // Inlined `enqueueUpdate` to remove interleaved update check - const updateQueue = fiber.updateQueue; - if (updateQueue === null) { - // Only occurs if the fiber has been unmounted. + if (fiber.tag === ClassComponent) { + // Schedule a force update on the work-in-progress. + const lane = pickArbitraryLane(renderLanes); + const update = createUpdate(NoTimestamp, lane); + 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. + + // Inlined `enqueueUpdate` to remove interleaved update check + const updateQueue = fiber.updateQueue; + if (updateQueue === null) { + // Only occurs if the fiber has been unmounted. + } else { + const sharedQueue: SharedQueue = (updateQueue: any).shared; + const pending = sharedQueue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; } else { - const sharedQueue: SharedQueue = (updateQueue: any).shared; - const pending = sharedQueue.pending; - if (pending === null) { - // This is the first update. Create a circular list. - update.next = update; - } else { - update.next = pending.next; - pending.next = update; - } - sharedQueue.pending = update; + update.next = pending.next; + pending.next = update; } + sharedQueue.pending = update; } - // Mark the updated lanes on the list, too. - list.lanes = mergeLanes(list.lanes, renderLanes); } fiber.lanes = mergeLanes(fiber.lanes, renderLanes); @@ -264,6 +287,9 @@ export function propagateContextChange( } scheduleWorkOnParentPath(fiber.return, renderLanes); + // Mark the updated lanes on the list, too. + list.lanes = mergeLanes(list.lanes, renderLanes); + // Since we already found a match, we can stop traversing the // dependency list. break; @@ -328,6 +354,247 @@ export function propagateContextChange( } } +function propagateContextChanges( + workInProgress: Fiber, + contexts: Array, + renderLanes: Lanes, + forcePropagateEntireTree: boolean, +): void { + // Only used by lazy implemenation + if (!enableLazyContextPropagation) { + 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; + + // Visit this fiber. + const list = fiber.dependencies; + if (list !== null) { + nextFiber = fiber.child; + + let dep = list.firstContext; + findChangedDep: while (dep !== null) { + // Assigning these to constants to help Flow + const dependency = dep; + const consumer = fiber; + findContext: for (let i = 0; i < contexts.length; i += 2) { + const context: ReactContext = contexts[i]; + const changedBits: number = contexts[i + 1]; + // Check if the context matches. + // TODO: Compare selected values to bail out early. + if ( + dependency.context === context && + (dependency.observedBits & changedBits) !== 0 + ) { + // Match! Schedule an update on this fiber. + + // In the lazy implemenation, don't mark a dirty flag on the + // dependency itself. Not all changes are propagated, so we can't + // rely on the propagation function alone to determine whether + // something has changed; the consumer will check. In the future, we + // could add back a dirty flag as an optimization to avoid double + // checking, but until we have selectors it's not really worth + // the trouble. + consumer.lanes = mergeLanes(consumer.lanes, renderLanes); + const alternate = consumer.alternate; + if (alternate !== null) { + alternate.lanes = mergeLanes(alternate.lanes, renderLanes); + } + scheduleWorkOnParentPath(consumer.return, renderLanes); + + if (!forcePropagateEntireTree) { + // During lazy propagation, when we find a match, we can defer + // propagating changes to the children, because we're going to + // visit them during render. We should continue propagating the + // siblings, though + nextFiber = null; + } + + // Since we already found a match, we can stop traversing the + // dependency list. + break findChangedDep; + } + } + dep = dependency.next; + } + } else if ( + enableSuspenseServerRenderer && + fiber.tag === DehydratedFragment + ) { + // If a dehydrated suspense boundary 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. + const parentSuspense = fiber.return; + invariant( + parentSuspense !== null, + 'We just came from a parent so we must have had a parent. This is a bug in React.', + ); + parentSuspense.lanes = mergeLanes(parentSuspense.lanes, renderLanes); + const alternate = parentSuspense.alternate; + if (alternate !== null) { + alternate.lanes = mergeLanes(alternate.lanes, renderLanes); + } + // 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 childLanes on + // this fiber to indicate that a context has changed. + scheduleWorkOnParentPath(parentSuspense, renderLanes); + nextFiber = null; + } 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; + } + const 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 lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const forcePropagateEntireTree = false; + propagateParentContextChanges( + current, + workInProgress, + renderLanes, + forcePropagateEntireTree, + ); +} + +// Used for propagating a deferred tree (Suspense, Offscreen). We must propagate +// to the entire subtree, because we won't revisit it until after the current +// render has completed, at which point we'll have lost track of which providers +// have changed. +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const forcePropagateEntireTree = true; + propagateParentContextChanges( + current, + workInProgress, + renderLanes, + forcePropagateEntireTree, + ); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + forcePropagateEntireTree: boolean, +) { + if (!enableLazyContextPropagation) { + return; + } + + // Collect all the parent providers that changed. Since this is usually small + // number, we use an Array instead of Set. + let contexts = null; + let parent = workInProgress; + let isInsidePropagationBailout = false; + while (parent !== null) { + if (!isInsidePropagationBailout) { + if ((parent.flags & NeedsPropagation) !== NoFlags) { + isInsidePropagationBailout = true; + } else if ((parent.flags & DidPropagateContext) !== NoFlags) { + break; + } + } + + if (parent.tag === ContextProvider) { + const currentParent = parent.alternate; + invariant( + currentParent !== null, + 'Should have a current fiber. This is a bug in React.', + ); + const oldProps = currentParent.memoizedProps; + if (oldProps !== null) { + const providerType: ReactProviderType = parent.type; + const context: ReactContext = providerType._context; + + const newProps = parent.pendingProps; + const newValue = newProps.value; + + const oldValue = oldProps.value; + + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits !== 0) { + if (contexts !== null) { + contexts.push(context, changedBits); + } else { + contexts = [context, changedBits]; + } + } + } + } + parent = parent.return; + } + + if (contexts !== null) { + // If there were any changed providers, search through the children and + // propagate their changes. + propagateContextChanges( + workInProgress, + contexts, + renderLanes, + forcePropagateEntireTree, + ); + } + + // This is an optimization so that we only propagate once per subtree. If a + // deeply nested child bails out, and it calls this propagation function, it + // uses this flag to know that the remaining ancestor providers have already + // been propagated. + // + // NOTE: This optimization is only necessary because we sometimes enter the + // begin phase of nodes that don't have any work scheduled on them — + // specifically, the siblings of a node that _does_ have scheduled work. The + // siblings will bail out and call this function again, even though we already + // propagated content changes to it and its subtree. So we use this flag to + // mark that the parent providers already propagated. + // + // Unfortunately, though, we need to ignore this flag when we're inside a + // tree whose context propagation was deferred — that's what the + // `NeedsPropagation` flag is for. + // + // If we could instead bail out before entering the siblings' begin phase, + // then we could remove both `DidPropagateContext` and `NeedsPropagation`. + // Consider this as part of the next refactor to the fiber tree structure. + workInProgress.flags |= DidPropagateContext; +} + export function checkIfContextChanged(currentDependencies: Dependencies) { if (!enableLazyContextPropagation) { return false; @@ -442,6 +709,9 @@ export function readContext( // TODO: This is an old field. Delete it. responders: null, }; + if (enableLazyContextPropagation) { + currentlyRenderingFiber.flags |= NeedsPropagation; + } } else { // Append a new context item. lastContextDependency = lastContextDependency.next = contextItem; diff --git a/packages/react-reconciler/src/ReactFiberNewContext.old.js b/packages/react-reconciler/src/ReactFiberNewContext.old.js index 602eadd6b010a..0e6ed587b8ddb 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.old.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.old.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactContext} from 'shared/ReactTypes'; +import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; import type { Fiber, ContextDependency, @@ -33,6 +33,11 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.old'; +import { + NoFlags, + DidPropagateContext, + NeedsPropagation, +} from './ReactFiberFlags'; import invariant from 'shared/invariant'; import is from 'shared/objectIs'; @@ -195,6 +200,37 @@ export function propagateContextChange( changedBits: number, renderLanes: Lanes, ): void { + if (enableLazyContextPropagation) { + // TODO: This path is only used by Cache components. Update + // lazilyPropagateParentContextChanges to look for Cache components so they + // can take advantage of lazy propagation. + const forcePropagateEntireTree = true; + propagateContextChanges( + workInProgress, + [context, changedBits], + renderLanes, + forcePropagateEntireTree, + ); + } else { + propagateContextChange_eager( + workInProgress, + context, + changedBits, + renderLanes, + ); + } +} + +function propagateContextChange_eager( + workInProgress: Fiber, + context: ReactContext, + changedBits: number, + renderLanes: Lanes, +): void { + // Only used by eager implemenation + if (enableLazyContextPropagation) { + return; + } let fiber = workInProgress.child; if (fiber !== null) { // Set the return pointer of the child to the work-in-progress fiber. @@ -216,45 +252,32 @@ export function propagateContextChange( (dependency.observedBits & changedBits) !== 0 ) { // Match! Schedule an update on this fiber. - - if (enableLazyContextPropagation) { - // In the lazy implemenation, don't mark a dirty flag on the - // dependency itself. Not all changes are propagated, so we can't - // rely on the propagation function alone to determine whether - // something has changed; the consumer will check. In the future, - // we could add back a dirty flag as an optimization to avoid - // double checking, but until we have selectors it's not really - // worth the trouble. - } else { - if (fiber.tag === ClassComponent) { - // Schedule a force update on the work-in-progress. - const lane = pickArbitraryLane(renderLanes); - const update = createUpdate(NoTimestamp, lane); - 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. - - // Inlined `enqueueUpdate` to remove interleaved update check - const updateQueue = fiber.updateQueue; - if (updateQueue === null) { - // Only occurs if the fiber has been unmounted. + if (fiber.tag === ClassComponent) { + // Schedule a force update on the work-in-progress. + const lane = pickArbitraryLane(renderLanes); + const update = createUpdate(NoTimestamp, lane); + 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. + + // Inlined `enqueueUpdate` to remove interleaved update check + const updateQueue = fiber.updateQueue; + if (updateQueue === null) { + // Only occurs if the fiber has been unmounted. + } else { + const sharedQueue: SharedQueue = (updateQueue: any).shared; + const pending = sharedQueue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; } else { - const sharedQueue: SharedQueue = (updateQueue: any).shared; - const pending = sharedQueue.pending; - if (pending === null) { - // This is the first update. Create a circular list. - update.next = update; - } else { - update.next = pending.next; - pending.next = update; - } - sharedQueue.pending = update; + update.next = pending.next; + pending.next = update; } + sharedQueue.pending = update; } - // Mark the updated lanes on the list, too. - list.lanes = mergeLanes(list.lanes, renderLanes); } fiber.lanes = mergeLanes(fiber.lanes, renderLanes); @@ -264,6 +287,9 @@ export function propagateContextChange( } scheduleWorkOnParentPath(fiber.return, renderLanes); + // Mark the updated lanes on the list, too. + list.lanes = mergeLanes(list.lanes, renderLanes); + // Since we already found a match, we can stop traversing the // dependency list. break; @@ -328,6 +354,247 @@ export function propagateContextChange( } } +function propagateContextChanges( + workInProgress: Fiber, + contexts: Array, + renderLanes: Lanes, + forcePropagateEntireTree: boolean, +): void { + // Only used by lazy implemenation + if (!enableLazyContextPropagation) { + 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; + + // Visit this fiber. + const list = fiber.dependencies; + if (list !== null) { + nextFiber = fiber.child; + + let dep = list.firstContext; + findChangedDep: while (dep !== null) { + // Assigning these to constants to help Flow + const dependency = dep; + const consumer = fiber; + findContext: for (let i = 0; i < contexts.length; i += 2) { + const context: ReactContext = contexts[i]; + const changedBits: number = contexts[i + 1]; + // Check if the context matches. + // TODO: Compare selected values to bail out early. + if ( + dependency.context === context && + (dependency.observedBits & changedBits) !== 0 + ) { + // Match! Schedule an update on this fiber. + + // In the lazy implemenation, don't mark a dirty flag on the + // dependency itself. Not all changes are propagated, so we can't + // rely on the propagation function alone to determine whether + // something has changed; the consumer will check. In the future, we + // could add back a dirty flag as an optimization to avoid double + // checking, but until we have selectors it's not really worth + // the trouble. + consumer.lanes = mergeLanes(consumer.lanes, renderLanes); + const alternate = consumer.alternate; + if (alternate !== null) { + alternate.lanes = mergeLanes(alternate.lanes, renderLanes); + } + scheduleWorkOnParentPath(consumer.return, renderLanes); + + if (!forcePropagateEntireTree) { + // During lazy propagation, when we find a match, we can defer + // propagating changes to the children, because we're going to + // visit them during render. We should continue propagating the + // siblings, though + nextFiber = null; + } + + // Since we already found a match, we can stop traversing the + // dependency list. + break findChangedDep; + } + } + dep = dependency.next; + } + } else if ( + enableSuspenseServerRenderer && + fiber.tag === DehydratedFragment + ) { + // If a dehydrated suspense boundary 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. + const parentSuspense = fiber.return; + invariant( + parentSuspense !== null, + 'We just came from a parent so we must have had a parent. This is a bug in React.', + ); + parentSuspense.lanes = mergeLanes(parentSuspense.lanes, renderLanes); + const alternate = parentSuspense.alternate; + if (alternate !== null) { + alternate.lanes = mergeLanes(alternate.lanes, renderLanes); + } + // 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 childLanes on + // this fiber to indicate that a context has changed. + scheduleWorkOnParentPath(parentSuspense, renderLanes); + nextFiber = null; + } 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; + } + const 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 lazilyPropagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const forcePropagateEntireTree = false; + propagateParentContextChanges( + current, + workInProgress, + renderLanes, + forcePropagateEntireTree, + ); +} + +// Used for propagating a deferred tree (Suspense, Offscreen). We must propagate +// to the entire subtree, because we won't revisit it until after the current +// render has completed, at which point we'll have lost track of which providers +// have changed. +export function propagateParentContextChangesToDeferredTree( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const forcePropagateEntireTree = true; + propagateParentContextChanges( + current, + workInProgress, + renderLanes, + forcePropagateEntireTree, + ); +} + +function propagateParentContextChanges( + current: Fiber, + workInProgress: Fiber, + renderLanes: Lanes, + forcePropagateEntireTree: boolean, +) { + if (!enableLazyContextPropagation) { + return; + } + + // Collect all the parent providers that changed. Since this is usually small + // number, we use an Array instead of Set. + let contexts = null; + let parent = workInProgress; + let isInsidePropagationBailout = false; + while (parent !== null) { + if (!isInsidePropagationBailout) { + if ((parent.flags & NeedsPropagation) !== NoFlags) { + isInsidePropagationBailout = true; + } else if ((parent.flags & DidPropagateContext) !== NoFlags) { + break; + } + } + + if (parent.tag === ContextProvider) { + const currentParent = parent.alternate; + invariant( + currentParent !== null, + 'Should have a current fiber. This is a bug in React.', + ); + const oldProps = currentParent.memoizedProps; + if (oldProps !== null) { + const providerType: ReactProviderType = parent.type; + const context: ReactContext = providerType._context; + + const newProps = parent.pendingProps; + const newValue = newProps.value; + + const oldValue = oldProps.value; + + const changedBits = calculateChangedBits(context, newValue, oldValue); + if (changedBits !== 0) { + if (contexts !== null) { + contexts.push(context, changedBits); + } else { + contexts = [context, changedBits]; + } + } + } + } + parent = parent.return; + } + + if (contexts !== null) { + // If there were any changed providers, search through the children and + // propagate their changes. + propagateContextChanges( + workInProgress, + contexts, + renderLanes, + forcePropagateEntireTree, + ); + } + + // This is an optimization so that we only propagate once per subtree. If a + // deeply nested child bails out, and it calls this propagation function, it + // uses this flag to know that the remaining ancestor providers have already + // been propagated. + // + // NOTE: This optimization is only necessary because we sometimes enter the + // begin phase of nodes that don't have any work scheduled on them — + // specifically, the siblings of a node that _does_ have scheduled work. The + // siblings will bail out and call this function again, even though we already + // propagated content changes to it and its subtree. So we use this flag to + // mark that the parent providers already propagated. + // + // Unfortunately, though, we need to ignore this flag when we're inside a + // tree whose context propagation was deferred — that's what the + // `NeedsPropagation` flag is for. + // + // If we could instead bail out before entering the siblings' begin phase, + // then we could remove both `DidPropagateContext` and `NeedsPropagation`. + // Consider this as part of the next refactor to the fiber tree structure. + workInProgress.flags |= DidPropagateContext; +} + export function checkIfContextChanged(currentDependencies: Dependencies) { if (!enableLazyContextPropagation) { return false; @@ -442,6 +709,9 @@ export function readContext( // TODO: This is an old field. Delete it. responders: null, }; + if (enableLazyContextPropagation) { + currentlyRenderingFiber.flags |= NeedsPropagation; + } } else { // Append a new context item. lastContextDependency = lastContextDependency.next = contextItem; diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 9ff0715f1621b..ba5a81ecccbf2 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -38,6 +38,7 @@ import {NoMode, BlockingMode, DebugTracingMode} from './ReactTypeOfMode'; import { enableDebugTracing, enableSchedulingProfiler, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -60,6 +61,7 @@ import { isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, } from './ReactFiberWorkLoop.new'; +import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; import {logCapturedError} from './ReactFiberErrorLogger'; import {logComponentSuspended} from './DebugTracing'; import {markComponentSuspended} from './SchedulingProfiler'; @@ -194,6 +196,23 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + // This is a wakeable. const wakeable: Wakeable = (value: any); diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index da0d90f057f6e..baa807e180dd0 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -38,6 +38,7 @@ import {NoMode, BlockingMode, DebugTracingMode} from './ReactTypeOfMode'; import { enableDebugTracing, enableSchedulingProfiler, + enableLazyContextPropagation, } from 'shared/ReactFeatureFlags'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -60,6 +61,7 @@ import { isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, } from './ReactFiberWorkLoop.old'; +import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; import {logCapturedError} from './ReactFiberErrorLogger'; import {logComponentSuspended} from './DebugTracing'; import {markComponentSuspended} from './SchedulingProfiler'; @@ -194,6 +196,23 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + // This is a wakeable. const wakeable: Wakeable = (value: any); diff --git a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js index bdc89014ad640..7d72ea717d767 100644 --- a/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js +++ b/packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js @@ -3,6 +3,11 @@ let ReactNoop; let Scheduler; let useState; let useContext; +let Suspense; +let SuspenseList; +let getCacheForType; +let caches; +let seededCache; describe('ReactLazyContextPropagation', () => { beforeEach(() => { @@ -13,17 +18,148 @@ describe('ReactLazyContextPropagation', () => { Scheduler = require('scheduler'); useState = React.useState; useContext = React.useContext; + Suspense = React.Suspense; + SuspenseList = React.unstable_SuspenseList; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; }); + // NOTE: These tests are not specific to the lazy propagation (as opposed to + // eager propagation). The behavior should be the same in both + // implementations. These are tests that are more relevant to the lazy + // propagation implementation, though. + + function createTextCache() { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const version = caches.length + 1; + const cache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error! [${text}]`); + throw record.value; + case 'resolved': + return textCache.version; + } + } else { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + function Text({text}) { Scheduler.unstable_yieldValue(text); return text; } - // NOTE: These tests are not specific to the lazy propagation (as opposed to - // eager propagation). The behavior should be the same in both - // implementations. These are tests that are more relevant to the lazy - // propagation implementation, though. + // function AsyncText({text, showVersion}) { + // const version = readText(text); + // const fullText = showVersion ? `${text} [v${version}]` : text; + // Scheduler.unstable_yieldValue(fullText); + // return text; + // } + + function seedNextTextCache(text) { + if (seededCache === null) { + seededCache = createTextCache(); + } + seededCache.resolve(text); + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + // function rejectMostRecentTextCache(text, error) { + // if (caches.length === 0) { + // throw Error('Cache does not exist.'); + // } else { + // // Resolve the most recently created cache. An older cache can by + // // resolved with `caches[index].reject(text, error)`. + // caches[caches.length - 1].reject(text, error); + // } + // } test( 'context change should prevent bailout of memoized component (useMemo -> ' + @@ -203,4 +339,584 @@ describe('ReactLazyContextPropagation', () => { expect(Scheduler).toHaveYielded(['Consumer']); expect(root).toMatchRenderedOutput('0'); }); + + // @gate enableCache + test('context is propagated across retries', async () => { + const root = ReactNoop.createRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + }> + + + + + ); + } + + function Async() { + const value = useContext(Context); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ; + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate enableCache + test('multiple contexts are propagated across retries', async () => { + // Same as previous test, but with multiple context providers + const root = ReactNoop.createRoot(); + + const Context1 = React.createContext('A'); + const Context2 = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + }> + + + + + + ); + } + + function Async() { + const value = useContext(Context1); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ( + <> + + + + ); + } + + const Indirection1 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + const Indirection2 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild1() { + const value = useContext(Context1); + return ; + } + + function DeepChild2() { + const value = useContext(Context2); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A', 'A']); + expect(root).toMatchRenderedOutput('AAA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BBB'); + }); + + // @gate enableCache + test('context is propagated across retries (legacy)', async () => { + const root = ReactNoop.createLegacyRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + }> + + + + + ); + } + + function Async() { + const value = useContext(Context); + readText(value); + + // When `readText` suspends, we haven't yet visited Indirection and all + // of its children. They won't get rendered until a later retry. + return ; + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + // Intentionally not wrapping in startTransition, so that the fallback + // the fallback displays despite this being a refresh. + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...', 'B']); + expect(root).toMatchRenderedOutput('Loading...B'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate experimental + test('context is propagated through offscreen trees', async () => { + const LegacyHidden = React.unstable_LegacyHidden; + + const root = ReactNoop.createRoot(); + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + + + + ); + } + + const Indirection = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate experimental + test('multiple contexts are propagated across through offscreen trees', async () => { + // Same as previous test, but with multiple context providers + const LegacyHidden = React.unstable_LegacyHidden; + + const root = ReactNoop.createRoot(); + + const Context1 = React.createContext('A'); + const Context2 = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + + + + + + + ); + } + + const Indirection1 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + const Indirection2 = React.memo(() => { + // This child must always be consistent with the sibling Text component. + return ; + }); + + function DeepChild1() { + const value = useContext(Context1); + return ; + } + + function DeepChild2() { + const value = useContext(Context2); + return ; + } + + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A', 'A']); + expect(root).toMatchRenderedOutput('AAA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B', 'B']); + expect(root).toMatchRenderedOutput('BBB'); + }); + + // @gate enableCache + // @gate experimental + test('contexts are propagated through SuspenseList', async () => { + // This kinda tests an implementation detail. SuspenseList has an early + // bailout that doesn't use `bailoutOnAlreadyFinishedWork`. It probably + // should just use that function, though. + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + const children = React.useMemo( + () => ( + + + + + ), + [], + ); + return {children}; + } + + function Child() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + test('nested bailouts', async () => { + // Lazy context propagation will stop propagating when it hits the first + // match. If we bail out again inside that tree, we must resume propagating. + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + ); + } + + const ChildIndirection = React.memo(() => { + return ; + }); + + function Child() { + const value = useContext(Context); + return ( + <> + + + + ); + } + + const DeepChildIndirection = React.memo(() => { + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate experimental + // @gate enableCache + test('nested bailouts across retries', async () => { + // Lazy context propagation will stop propagating when it hits the first + // match. If we bail out again inside that tree, we must resume propagating. + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + }> + + + + ); + } + + function Async({value}) { + // When this suspends, we won't be able to visit its children during the + // current render. So we must take extra care to propagate the context + // change in such a way that they're aren't lost when we retry in a + // later render. + readText(value); + return ; + } + + function Child() { + const value = useContext(Context); + return ( + <> + + + + ); + } + + const DeepChildIndirection = React.memo(() => { + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await seedNextTextCache('A'); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + // @gate experimental + test('nested bailouts through offscreen trees', async () => { + // Lazy context propagation will stop propagating when it hits the first + // match. If we bail out again inside that tree, we must resume propagating. + + const LegacyHidden = React.unstable_LegacyHidden; + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + + + ); + } + + function Child() { + const value = useContext(Context); + return ( + <> + + + + ); + } + + const DeepChildIndirection = React.memo(() => { + return ; + }); + + function DeepChild() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); + + test('finds context consumers in multiple sibling branches', async () => { + // This test confirms that when we find a matching context consumer during + // propagation, we continue propagating to its sibling branches. + + const Context = React.createContext('A'); + + let setContext; + function App() { + const [value, setValue] = useState('A'); + setContext = setValue; + return ( + + + + ); + } + + const Blah = React.memo(() => { + return ( + <> + + + + ); + }); + + const Indirection = React.memo(() => { + return ; + }); + + function Child() { + const value = useContext(Context); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'A']); + expect(root).toMatchRenderedOutput('AA'); + + await ReactNoop.act(async () => { + setContext('B'); + }); + expect(Scheduler).toHaveYielded(['B', 'B']); + expect(root).toMatchRenderedOutput('BB'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index 3f7f4f992955b..43c1e0033c2f3 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -689,6 +689,7 @@ describe('ReactNewContext', () => { ]); }); + // @gate !enableLazyContextPropagation it('can skip parents with bitmask bailout while updating their children', () => { const Context = React.createContext({foo: 0, bar: 0}, (a, b) => { let result = 0; @@ -1077,10 +1078,13 @@ describe('ReactNewContext', () => { // Update ReactNoop.render(); - expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( - 'calculateChangedBits: Expected the return value to be a 31-bit ' + - 'integer. Instead received: 4294967295', - ); + + if (gate(flags => !flags.enableLazyContextPropagation)) { + expect(() => expect(Scheduler).toFlushWithoutYielding()).toErrorDev( + 'calculateChangedBits: Expected the return value to be a 31-bit ' + + 'integer. Instead received: 4294967295', + ); + } }); it('warns if no value prop provided', () => { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c29a136a1e1ec..fc89fb5ca39ba 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -374,5 +374,7 @@ "383": "This query has received fewer parameters than the last time the same query was used. Always pass the exact number of parameters that the query needs.", "384": "Refreshing the cache is not supported in Server Components.", "385": "A mutable source was mutated while the %s component was rendering. This is not supported. Move any mutations into event handlers or effects.", - "386": "The current renderer does not support microtasks. This error is likely caused by a bug in React. Please file an issue." + "386": "The current renderer does not support microtasks. This error is likely caused by a bug in React. Please file an issue.", + "387": "Should have a current fiber. This is a bug in React.", + "388": "Expected to find a bailed out fiber. This is a bug in React." }