diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 81f2081f2fe..cb58922dd6d 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -456,7 +456,14 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { }, renderLegacySyncRoot(element: React$Element, callback: ?Function) { - const rootID = DEFAULT_ROOT_ID; + ReactNoop.renderLegacySyncRootWithID(element, DEFAULT_ROOT_ID, callback); + }, + + renderLegacySyncRootWithID( + element: React$Element, + rootID: string, + callback: ?Function, + ) { const isAsync = false; const container = ReactNoop.getOrCreateRootContainer(rootID, isAsync); const root = roots.get(container.rootID); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 84a835a9e00..52613dcba30 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -74,6 +74,7 @@ import { readContext, prepareToReadContext, calculateChangedBits, + pushRootContexts, } from './ReactFiberNewContext'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { @@ -432,6 +433,7 @@ function pushHostRootContext(workInProgress) { pushTopLevelContextObject(workInProgress, root.context, false); } pushHostContainer(workInProgress, root.containerInfo); + pushRootContexts(workInProgress); } function updateHostRoot(current, workInProgress, renderExpirationTime) { @@ -445,15 +447,17 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { ); const nextProps = workInProgress.pendingProps; const prevState = workInProgress.memoizedState; - const prevChildren = prevState !== null ? prevState.element : null; + const prevChildren = prevState.element; processUpdateQueue( workInProgress, updateQueue, nextProps, - null, + workInProgress, renderExpirationTime, ); + const nextState = workInProgress.memoizedState; + // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 3ccd0c451cc..7bb46d17319 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -18,6 +18,7 @@ import type {Fiber} from './ReactFiber'; import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {CapturedValue, CapturedError} from './ReactCapturedValue'; +import type {UpdateQueue} from './ReactUpdateQueue'; import {enableProfilerTimer, enableSuspense} from 'shared/ReactFeatureFlags'; import { @@ -272,7 +273,9 @@ function commitLifeCycles( return; } case HostRoot: { - const updateQueue = finishedWork.updateQueue; + const updateQueue: UpdateQueue< + any, + > | null = (finishedWork.updateQueue: any); if (updateQueue !== null) { let instance = null; if (finishedWork.child !== null) { @@ -291,6 +294,9 @@ function commitLifeCycles( instance, committedExpirationTime, ); + if (updateQueue.firstUpdate === null) { + finishedWork.memoizedState.contexts = updateQueue.baseState.contexts = new Map(); + } } return; } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index fae0ac77d4a..6b5028aaa31 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -62,7 +62,7 @@ import { popContextProvider as popLegacyContextProvider, popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext'; -import {popProvider} from './ReactFiberNewContext'; +import {popProvider, popRootContexts} from './ReactFiberNewContext'; import { prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, @@ -322,6 +322,7 @@ function completeWork( case HostRoot: { popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + popRootContexts(workInProgress); const fiberRoot = (workInProgress.stateNode: FiberRoot); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index 39c190431f8..25a980c3198 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -20,20 +20,21 @@ export type ContextDependency = { import warningWithoutStack from 'shared/warningWithoutStack'; import {isPrimaryRenderer} from './ReactFiberHostConfig'; +import { + scheduleWork, + computeExpirationForFiber, + requestCurrentTime, +} from './ReactFiberScheduler'; import {createCursor, push, pop} from './ReactFiberStack'; import maxSigned31BitInt from './maxSigned31BitInt'; import {NoWork} from './ReactFiberExpirationTime'; import {ContextProvider, ClassComponent} from 'shared/ReactTypeOfWork'; +import {Update} from 'shared/ReactTypeOfSideEffect'; +import {enqueueUpdate, createUpdate, ForceUpdate} from './ReactUpdateQueue'; import invariant from 'shared/invariant'; import warning from 'shared/warning'; -import { - createUpdate, - enqueueUpdate, - ForceUpdate, -} from 'react-reconciler/src/ReactUpdateQueue'; -import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; const valueCursor: StackCursor = createCursor(null); let rendererSigil; @@ -103,8 +104,8 @@ export function popProvider(providerFiber: Fiber): void { export function calculateChangedBits( context: ReactContext, - newValue: T, oldValue: T, + newValue: T, ) { // Use Object.is to compare the new context value to the old value. Inlined // Object.is polyfill. @@ -120,11 +121,11 @@ export function calculateChangedBits( const changedBits = typeof context._calculateChangedBits === 'function' ? context._calculateChangedBits(oldValue, newValue) - : MAX_SIGNED_31_BIT_INT; + : maxSigned31BitInt; if (__DEV__) { warning( - (changedBits & MAX_SIGNED_31_BIT_INT) === changedBits, + (changedBits & maxSigned31BitInt) === changedBits, 'calculateChangedBits: Expected the return value to be a ' + '31-bit integer. Instead received: %s', changedBits, @@ -134,9 +135,79 @@ export function calculateChangedBits( } } +export function setRootContext( + fiber: Fiber, + context: ReactContext, + currentValueAtTimeOfUpdate: T, + newValue: T, + callback: (() => mixed) | null, +) { + // Schedule an update on the root. + const currentTime = requestCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); + const update = createUpdate(expirationTime); + const changedBits = calculateChangedBits( + context, + currentValueAtTimeOfUpdate, + newValue, + ); + update.payload = function processUpdate(state) { + const workInProgress = this; + const baseContexts = state.contexts; + if (!baseContexts.has(context)) { + baseContexts.set(context, currentValueAtTimeOfUpdate); + } + if (isPrimaryRenderer) { + context._currentValue = newValue; + context._changedBits = changedBits; + } else { + context._currentValue2 = newValue; + context._changedBits2 = changedBits; + } + workInProgress.effectTag |= Update; + propagateContextChange( + workInProgress, + context, + changedBits, + expirationTime, + ); + return null; + }; + update.callback = callback; + enqueueUpdate(fiber, update); + scheduleWork(fiber, expirationTime); +} + +export function pushRootContexts(workInProgress: Fiber): void { + const contexts = workInProgress.memoizedState.contexts; + contexts.forEach((value, context) => { + if (isPrimaryRenderer) { + context._currentValue = value; + context._changedBits = 0; + } else { + context._currentValue2 = value; + context._changedBits2 = 0; + } + }); +} + +export function popRootContexts(workInProgress: Fiber): void { + const contexts = workInProgress.memoizedState.contexts; + contexts.forEach((oldValue, context) => { + const globalValue = context._globalValue; + if (isPrimaryRenderer) { + context._currentValue = globalValue; + context._changedBits = 0; + } else { + context._currentValue2 = globalValue; + context._changedBits2 = 0; + } + }); +} + export function propagateContextChange( workInProgress: Fiber, - context: ReactContext, + context: ReactContext, changedBits: number, renderExpirationTime: ExpirationTime, ): void { @@ -220,7 +291,8 @@ export function propagateContextChange( } while (dependency !== null); } else if (fiber.tag === ContextProvider) { // Don't scan deeper if this is a matching provider - nextFiber = fiber.type === workInProgress.type ? null : fiber.child; + const providerContext: ReactContext = fiber.type._context; + nextFiber = providerContext === context ? null : fiber.child; } else { // Traverse down. nextFiber = fiber.child; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 140b75e3aa7..d8b4aa4957a 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -18,6 +18,7 @@ import type { import type {ReactNodeList} from 'shared/ReactTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; import { findCurrentHostFiber, findCurrentHostFiberWithNoPortals, @@ -57,6 +58,8 @@ import {createUpdate, enqueueUpdate} from './ReactUpdateQueue'; import ReactFiberInstrumentation from './ReactFiberInstrumentation'; import * as ReactCurrentFiber from './ReactCurrentFiber'; +const {ReactRootList} = ReactSharedInternals; + type OpaqueRoot = FiberRoot; // 0 is PROD, 1 is DEV. @@ -141,6 +144,22 @@ function scheduleRootUpdate( return expirationTime; } +function unmountRootFromGlobalList(root) { + // This root is no longer mounted. Remove it from the global list. + const previous = root.previousGlobalRoot; + const next = root.nextGlobalRoot; + if (previous !== null) { + previous.nextGlobalRoot = next; + } else { + ReactRootList.first = next; + } + if (next !== null) { + next.previousGlobalRoot = previous; + } else { + ReactRootList.last = previous; + } +} + export function updateContainerAtExpirationTime( element: ReactNodeList, container: OpaqueRoot, @@ -163,6 +182,25 @@ export function updateContainerAtExpirationTime( } } + let wrappedCallback; + if (element === null) { + // Assume this is an unmount and mark the root for clean-up from the + // global list. + // TODO: Add an explicit API for unmounting to the reconciler API, instead + // of inferring based on the children. + if (callback !== null && callback !== undefined) { + const cb = callback; + wrappedCallback = function() { + unmountRootFromGlobalList(container); + return cb.call(this); + }; + } else { + wrappedCallback = unmountRootFromGlobalList.bind(null, container); + } + } else { + wrappedCallback = callback; + } + const context = getContextForSubtree(parentComponent); if (container.context === null) { container.context = context; @@ -170,7 +208,7 @@ export function updateContainerAtExpirationTime( container.pendingContext = context; } - return scheduleRootUpdate(current, element, expirationTime, callback); + return scheduleRootUpdate(current, element, expirationTime, wrappedCallback); } function findHostInstance(component: Object): PublicInstance | null { diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 22ee98db32e..b7d0d2b93fa 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -7,14 +7,19 @@ * @flow */ +import type {ReactContext} from 'shared/ReactTypes'; import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; +import {setRootContext} from './ReactFiberNewContext'; + +const {ReactRootList} = ReactSharedInternals; // TODO: This should be lifted into the renderer. export type Batch = { @@ -73,6 +78,18 @@ export type FiberRoot = { firstBatch: Batch | null, // Linked-list of roots nextScheduledRoot: FiberRoot | null, + + // Linked-list of global roots. This is cross-renderer. + nextGlobalRoot: FiberRoot | null, + previousGlobalRoot: FiberRoot | null, + + // Schedules a context update. + setContext( + context: ReactContext, + oldValue: T, + newValue: T, + callback: () => mixed, + ): void, }; export function createFiberRoot( @@ -80,6 +97,8 @@ export function createFiberRoot( isAsync: boolean, hydrate: boolean, ): FiberRoot { + const lastGlobalRoot = ReactRootList.last; + // Cyclic construction. This cheats the type system right now because // stateNode is any. const uninitializedFiber = createHostRootFiber(isAsync); @@ -106,7 +125,24 @@ export function createFiberRoot( expirationTime: NoWork, firstBatch: null, nextScheduledRoot: null, + + nextGlobalRoot: null, + previousGlobalRoot: lastGlobalRoot, + setContext: (setRootContext.bind(null, uninitializedFiber): any), }; uninitializedFiber.stateNode = root; + uninitializedFiber.memoizedState = { + element: null, + contexts: new Map(), + }; + + // Append to the global list of roots + if (lastGlobalRoot === null) { + ReactRootList.first = root; + } else { + lastGlobalRoot.nextGlobalRoot = root; + } + ReactRootList.last = root; + return root; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index b8bad42320a..91f87758246 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -113,7 +113,11 @@ import { popTopLevelContextObject as popTopLevelLegacyContextObject, popContextProvider as popLegacyContextProvider, } from './ReactFiberContext'; -import {popProvider, resetContextDependences} from './ReactFiberNewContext'; +import { + popProvider, + resetContextDependences, + popRootContexts, +} from './ReactFiberNewContext'; import {popHostContext, popHostContainer} from './ReactFiberHostContext'; import { recordCommitTime, @@ -283,6 +287,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { case HostRoot: popHostContainer(failedUnitOfWork); popTopLevelLegacyContextObject(failedUnitOfWork); + popRootContexts(failedUnitOfWork); break; case HostComponent: popHostContext(failedUnitOfWork); diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 1fd1d4fe0e8..c01e790dcf4 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -50,7 +50,7 @@ import { popContextProvider as popLegacyContextProvider, popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext'; -import {popProvider} from './ReactFiberNewContext'; +import {popProvider, popRootContexts} from './ReactFiberNewContext'; import { renderDidSuspend, renderDidError, @@ -391,6 +391,7 @@ function unwindWork( case HostRoot: { popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + popRootContexts(workInProgress); const effectTag = workInProgress.effectTag; invariant( (effectTag & DidCapture) === NoEffect, @@ -432,6 +433,7 @@ function unwindInterruptedWork(interruptedWork: Fiber) { case HostRoot: { popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); + popRootContexts(interruptedWork); break; } case HostComponent: { diff --git a/packages/react-reconciler/src/__tests__/ReactContextUpdates-test.internal.js b/packages/react-reconciler/src/__tests__/ReactContextUpdates-test.internal.js new file mode 100644 index 00000000000..7bb63613434 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextUpdates-test.internal.js @@ -0,0 +1,412 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let ReactFeatureFlags; +let React; +let ReactNoop; + +describe('ReactContextUpdates', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + }); + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + it('simple update', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + function App() { + return ( + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Theme: light', + 'Sibling', + 'Theme: light', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Theme: light'), + span('Sibling'), + span('Theme: light'), + ]); + + ThemeState.unstable_set('dark'); + expect(ReactNoop.flush()).toEqual(['Theme: dark', 'Theme: dark']); + expect(ReactNoop.getChildren()).toEqual([ + span('Theme: dark'), + span('Sibling'), + span('Theme: dark'), + ]); + }); + + it('updates multiple roots', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + expect(ReactNoop.flush()).toEqual(['Theme: light', 'Theme: light']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: light')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: light')]); + + // Update the global state. Both roots should update. + ThemeState.unstable_set('dark'); + expect(ReactNoop.flush()).toEqual(['Theme: dark', 'Theme: dark']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: dark')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: dark')]); + }); + + it('accepts a callback', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + function App() { + return ( + + + + + + ); + } + + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + expect(ReactNoop.flush()).toEqual([ + // Root a + 'Before', + 'Theme: light', + 'After', + // Root b + 'Before', + 'Theme: light', + 'After', + ]); + expect(ReactNoop.getChildren('a')).toEqual([ + span('Before'), + span('Theme: light'), + span('After'), + ]); + expect(ReactNoop.getChildren('b')).toEqual([ + span('Before'), + span('Theme: light'), + span('After'), + ]); + + // Update the global state. Both roots should update. + ThemeState.unstable_set('dark', () => { + ReactNoop.yield('Did call callback'); + }); + + // This will render the first root and yield right before committing. + expect(ReactNoop.flushNextYield()).toEqual(['Theme: dark']); + // The children haven't updated yet, and the callback was not called. + expect(ReactNoop.getChildren('a')).toEqual([ + span('Before'), + span('Theme: light'), + span('After'), + ]); + expect(ReactNoop.getChildren('b')).toEqual([ + span('Before'), + span('Theme: light'), + span('After'), + ]); + + // This will commit the first root and render the second root, but without + // committing the second root. + expect(ReactNoop.flushNextYield()).toEqual(['Theme: dark']); + // The first root has updated, but not the second one. The callback still + // hasn't been called, because it's waiting for b to commit. + expect(ReactNoop.getChildren('a')).toEqual([ + span('Before'), + span('Theme: dark'), + span('After'), + ]); + expect(ReactNoop.getChildren('b')).toEqual([ + span('Before'), + span('Theme: light'), + span('After'), + ]); + + // Now commit the second root. The callback is called. + expect(ReactNoop.flush()).toEqual(['Did call callback']); + expect(ReactNoop.getChildren('a')).toEqual([ + span('Before'), + span('Theme: dark'), + span('After'), + ]); + expect(ReactNoop.getChildren('b')).toEqual([ + span('Before'), + span('Theme: dark'), + span('After'), + ]); + }); + + it('works across sync and async roots', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + ReactNoop.renderLegacySyncRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + expect(ReactNoop.flush()).toEqual(['Theme: light', 'Theme: light']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: light')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: light')]); + + ThemeState.unstable_set('dark', () => { + ReactNoop.yield('Did call callback'); + }); + // Root a is synchronous, so it already updated. The callback shouldn't + // have fired yet, though, because root b is still pending. + expect(ReactNoop.clearYields()).toEqual(['Theme: dark']); + + // Flush the remaining work and fire the callback. + expect(ReactNoop.flush()).toEqual(['Theme: dark', 'Did call callback']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: dark')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: dark')]); + }); + + it('unmounts a root that reads from global state', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + expect(ReactNoop.flush()).toEqual(['Theme: light', 'Theme: light']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: light')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: light')]); + + // Update the global state. Both roots should update. + ThemeState.unstable_set('dark'); + expect(ReactNoop.flush()).toEqual(['Theme: dark', 'Theme: dark']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: dark')]); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: dark')]); + + // Unmount one of the roots + ReactNoop.unmountRootWithID('a'); + expect(ReactNoop.flush()).toEqual([]); + expect(ReactNoop.getChildren('a')).toEqual(null); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: dark')]); + + // Update again + ThemeState.unstable_set('blue'); + expect(ReactNoop.flush()).toEqual(['Theme: blue']); + expect(ReactNoop.getChildren('a')).toEqual(null); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: blue')]); + }); + + it('passes updated global state to new roots', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + ReactNoop.renderToRootWithID(, 'a'); + + expect(ReactNoop.flush()).toEqual(['Theme: light']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: light')]); + + ThemeState.unstable_set('dark'); + expect(ReactNoop.flush()).toEqual(['Theme: dark']); + expect(ReactNoop.getChildren('a')).toEqual([span('Theme: dark')]); + + ReactNoop.renderToRootWithID(, 'b'); + expect(ReactNoop.flush()).toEqual(['Theme: dark']); + expect(ReactNoop.getChildren('b')).toEqual([span('Theme: dark')]); + }); + + it('passes latest value to roots created in the middle of a context transition', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + function App() { + return ( + + + + + ); + } + + ReactNoop.renderToRootWithID(, 'a'); + + expect(ReactNoop.flush()).toEqual(['Theme: light', 'Theme: light']); + expect(ReactNoop.getChildren('a')).toEqual([ + span('Theme: light'), + span('Theme: light'), + ]); + + ThemeState.unstable_set('dark'); + ReactNoop.flushThrough(['Theme: dark']); + + ReactNoop.renderLegacySyncRootWithID(, 'b'); + expect(ReactNoop.clearYields()).toEqual(['Theme: dark', 'Theme: dark']); + expect(ReactNoop.getChildren('b')).toEqual([ + span('Theme: dark'), + span('Theme: dark'), + ]); + + expect(ReactNoop.flush()).toEqual(['Theme: dark', 'Theme: dark']); + expect(ReactNoop.getChildren('a')).toEqual([ + span('Theme: dark'), + span('Theme: dark'), + ]); + }); + + it('supports nested providers', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + function App() { + return ( + + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Theme: light', 'Theme: blue']); + expect(ReactNoop.getChildren()).toEqual([ + span('Theme: light'), + span('Theme: blue'), + ]); + + ThemeState.unstable_set('dark'); + expect(ReactNoop.flush()).toEqual(['Theme: dark']); + expect(ReactNoop.getChildren()).toEqual([ + span('Theme: dark'), + span('Theme: blue'), + ]); + }); + + it('calls callback immediately if there are no consumers', () => { + const ThemeState = React.createContext('light'); + ThemeState.unstable_set('dark', () => { + ReactNoop.yield('Did call callback'); + }); + expect(ReactNoop.clearYields()).toEqual(['Did call callback']); + }); + + it('queues updates at multiple priority levels', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Theme: light']); + expect(ReactNoop.getChildren()).toEqual([span('Theme: light')]); + + ThemeState.unstable_set('dark'); + ReactNoop.flushSync(() => { + ThemeState.unstable_set('blue'); + }); + expect(ReactNoop.clearYields()).toEqual(['Theme: blue']); + expect(ReactNoop.getChildren()).toEqual([span('Theme: blue')]); + + expect(ReactNoop.flush()).toEqual(['Theme: blue']); + expect(ReactNoop.getChildren()).toEqual([span('Theme: blue')]); + }); + + it('interrupts a low priority context update', () => { + const ThemeState = React.createContext('light'); + + function ThemedLabel() { + const theme = ThemeState.unstable_read(); + return ; + } + + class Stateful extends React.Component { + state = {step: 1}; + render() { + const theme = ThemeState.unstable_read(); + return ; + } + } + + const stateful = React.createRef(null); + ReactNoop.render( + + + + , + ); + + expect(ReactNoop.flush()).toEqual(['Step 1 (light)', 'Theme: light']); + + ThemeState.unstable_set('dark'); + ReactNoop.flushThrough(['Step 1 (dark)']); + + ReactNoop.flushSync(() => { + stateful.current.setState({step: 2}); + }); + expect(ReactNoop.clearYields()).toEqual(['Step 2 (light)']); + + expect(ReactNoop.flush()).toEqual(['Step 2 (dark)', 'Theme: dark']); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 85cf6b6fbda..95d78b8f9de 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -291,6 +291,47 @@ describe('ReactNewContext', () => { expect(ReactNoop.getChildren()).toEqual([span('Result: 12')]); }); + it('a change in an ancestor provider does not update consumers within a nested provider', () => { + const ThemeContext = React.createContext('light'); + const ThemeConsumer = getConsumer(ThemeContext); + class Parent extends React.Component { + state = {theme: 'light'}; + render() { + return ( + + + + + + + ); + } + } + + class Child extends React.PureComponent { + render() { + return ; + } + } + + function ThemedText(props) { + return ( + {theme => } + ); + } + + const parent = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['light', 'blue']); + expect(ReactNoop.getChildren()).toEqual([span('light'), span('blue')]); + + parent.current.setState({theme: 'dark'}); + // Only one of the consumers should re-render. The one nested inside + // a provider does not need to update. + expect(ReactNoop.flush()).toEqual(['dark']); + expect(ReactNoop.getChildren()).toEqual([span('dark'), span('blue')]); + }); + it('should provide the correct (default) values to consumers outside of a provider', () => { const FooContext = React.createContext({value: 'foo-initial'}); const BarContext = React.createContext({value: 'bar-initial'}); diff --git a/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap b/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap index f6c7cb67b62..a933095bedb 100644 --- a/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap +++ b/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap @@ -38,9 +38,9 @@ exports[`ReactDebugFiberPerf captures all lifecycles 1`] = ` ⚛ (Committing Changes) ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) + ⚛ (Committing Host Effects: 2 Total) ⚛ AllLifecycles.componentWillUnmount - ⚛ (Calling Lifecycle Methods: 0 Total) + ⚛ (Calling Lifecycle Methods: 1 Total) " `; @@ -203,8 +203,8 @@ exports[`ReactDebugFiberPerf measures a simple reconciliation 1`] = ` ⚛ (Committing Changes) ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) + ⚛ (Committing Host Effects: 2 Total) + ⚛ (Calling Lifecycle Methods: 1 Total) " `; diff --git a/packages/react/src/ReactContext.js b/packages/react/src/ReactContext.js index 85cf82ff158..2e73f4c8941 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -15,8 +15,67 @@ import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; import ReactCurrentOwner from './ReactCurrentOwner'; +import ReactRootList from './ReactRootList'; -export function readContext( +function contextDidUpdate(context: ReactContext, newValue: T) { + context._currentValue = context._currentValue2 = newValue; +} + +function setContext( + context: ReactContext, + newValue: T, + userCallback: (() => mixed) | void | null, +): void { + const oldValue = context._globalValue; + context._globalValue = newValue; + + let wrappedCallback = null; + + if (userCallback !== null && userCallback !== undefined) { + const cb = userCallback; + // Use reference counting to wait until all roots have updated before + // calling the callback. + let numRootsThatNeedUpdate = 0; + let root = ReactRootList.first; + if (root !== null) { + do { + numRootsThatNeedUpdate += 1; + root = root.nextGlobalRoot; + } while (root !== null); + wrappedCallback = committedValue => { + numRootsThatNeedUpdate -= 1; + if (numRootsThatNeedUpdate === 0) { + contextDidUpdate(context, newValue); + cb(); + } + }; + } else { + // There are no mounted roots. Fire the callback and exit. + contextDidUpdate(context, newValue); + userCallback(); + return; + } + } else { + if (ReactRootList.first !== null) { + wrappedCallback = contextDidUpdate.bind(null, context, newValue); + } else { + // There are no mounted roots. Exit. + contextDidUpdate(context, newValue); + return; + } + } + + // Schedule an update on each root. We do this in a separate loop from the + // one above, because in sync mode, `setContext` may not be batched. + let root = ReactRootList.first; + while (root !== null) { + // Pass the old value so React can calculate the changed bits + root.setContext(context, oldValue, newValue, wrappedCallback); + root = root.nextGlobalRoot; + } +} + +function readContext( context: ReactContext, observedBits: void | number | boolean, ): T { @@ -50,6 +109,7 @@ export function createContext( const context: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, _calculateChangedBits: calculateChangedBits, + _globalValue: defaultValue, // As a workaround to support multiple concurrent renderers, we categorize // some renderers as primary and others as secondary. We only expect // there to be two concurrent renderers at most: React Native (primary) and @@ -61,6 +121,7 @@ export function createContext( Provider: (null: any), Consumer: (null: any), unstable_read: (null: any), + unstable_set: (null: any), }; context.Provider = { @@ -69,6 +130,7 @@ export function createContext( }; context.Consumer = context; context.unstable_read = readContext.bind(null, context); + context.unstable_set = setContext.bind(null, context); if (__DEV__) { context._currentRenderer = null; diff --git a/packages/react/src/ReactRootList.js b/packages/react/src/ReactRootList.js new file mode 100644 index 00000000000..b2b32c13cc1 --- /dev/null +++ b/packages/react/src/ReactRootList.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; + +type GlobalRoot = { + isMounted: boolean, + setContext( + context: ReactContext, + oldVvalue: T, + newValue: T, + callback: (T) => mixed, + ): void, + previousGlobalRoot: GlobalRoot | null, + nextGlobalRoot: GlobalRoot | null, +}; + +const ReactRootList = { + first: (null: GlobalRoot | null), + last: (null: GlobalRoot | null), +}; + +export default ReactRootList; diff --git a/packages/react/src/ReactSharedInternals.js b/packages/react/src/ReactSharedInternals.js index 8b179aff842..ccedb93ee3a 100644 --- a/packages/react/src/ReactSharedInternals.js +++ b/packages/react/src/ReactSharedInternals.js @@ -7,10 +7,12 @@ import assign from 'object-assign'; import ReactCurrentOwner from './ReactCurrentOwner'; +import ReactRootList from './ReactRootList'; import ReactDebugCurrentFrame from './ReactDebugCurrentFrame'; const ReactSharedInternals = { ReactCurrentOwner, + ReactRootList, // Used by renderers to avoid bundling object-assign twice in UMD bundles: assign, }; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 36d1a40008b..ccde35cfa4a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -79,9 +79,12 @@ export type ReactContext = { $$typeof: Symbol | number, Consumer: ReactContext, Provider: ReactProviderType, + unstable_read: () => T, + unstable_set: (value: T, callback: (() => mixed) | void | null) => void, _calculateChangedBits: ((a: T, b: T) => number) | null, + _globalValue: T, _currentValue: T, _currentValue2: T,