diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 81f2081f2fe4ae..cb58922dd6d887 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 fb71d185819639..7b2dfe0376c7c2 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -73,7 +73,7 @@ import { propagateContextChange, readContext, prepareToReadContext, - calculateChangedBits, + propagateRootContextChanges, } from './ReactFiberNewContext'; import { markActualRenderTimeStarted, @@ -99,7 +99,8 @@ import { resumeMountClassInstance, updateClassInstance, } from './ReactFiberClassComponent'; -import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; +import MAX_SIGNED_31_BIT_INT from 'shared/maxSigned31BitInt'; +import calculateChangedBits from 'shared/calculateChangedBits'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -412,7 +413,7 @@ function finishClassComponent( return workInProgress.child; } -function pushHostRootContext(workInProgress) { +function pushHostRootContext(workInProgress, renderExpirationTime) { const root = (workInProgress.stateNode: FiberRoot); if (root.pendingContext) { pushTopLevelContextObject( @@ -428,7 +429,7 @@ function pushHostRootContext(workInProgress) { } function updateHostRoot(current, workInProgress, renderExpirationTime) { - pushHostRootContext(workInProgress); + pushHostRootContext(workInProgress, renderExpirationTime); const updateQueue = workInProgress.updateQueue; invariant( updateQueue !== null, @@ -438,7 +439,7 @@ 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, @@ -447,6 +448,17 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { renderExpirationTime, ); const nextState = workInProgress.memoizedState; + + let contextUpdate = nextState.firstContextUpdate; + if (contextUpdate !== null) { + propagateRootContextChanges( + workInProgress, + contextUpdate, + renderExpirationTime, + ); + nextState.firstContextUpdate = null; + } + // Caution: React DevTools currently depends on this property // being called "element". const nextChildren = nextState.element; @@ -826,7 +838,7 @@ function updateContextProvider(current, workInProgress, renderExpirationTime) { changedBits = MAX_SIGNED_31_BIT_INT; } else { const oldValue = oldProps.value; - changedBits = calculateChangedBits(context, newValue, oldValue); + changedBits = calculateChangedBits(context, oldValue, newValue); if (changedBits === 0) { // No change. Bailout early if children are the same. if ( @@ -980,7 +992,7 @@ function beginWork( // in this optimized path, mostly pushing stuff onto the stack. switch (workInProgress.tag) { case HostRoot: - pushHostRootContext(workInProgress); + pushHostRootContext(workInProgress, renderExpirationTime); resetHydrationState(); break; case HostComponent: diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index e8f8e1b1e22e3d..dd5449202a7600 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -20,15 +20,26 @@ 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} from 'shared/ReactTypeOfWork'; +import {enqueueUpdate, createUpdate} from './ReactUpdateQueue'; import invariant from 'shared/invariant'; -import warning from 'shared/warning'; -import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; +type RootContextUpdate = {| + context: ReactContext, + value: T, + changedBits: number, + next: RootContextUpdate | null, +|}; + const valueCursor: StackCursor = createCursor(null); const changedBitsCursor: StackCursor = createCursor(0); @@ -105,37 +116,59 @@ export function popProvider(providerFiber: Fiber): void { } } -export function calculateChangedBits( +export function setRootContext( + fiber: Fiber, context: ReactContext, - newValue: T, - oldValue: T, + value: T, + changedBits: number, + callback: (() => mixed) | null, ) { - // Use Object.is to compare the new context value to the old value. Inlined - // Object.is polyfill. - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is - if ( - (oldValue === newValue && - (oldValue !== 0 || 1 / oldValue === 1 / (newValue: any))) || - (oldValue !== oldValue && newValue !== newValue) // eslint-disable-line no-self-compare - ) { - // No change - return 0; - } else { - const changedBits = - typeof context._calculateChangedBits === 'function' - ? context._calculateChangedBits(oldValue, newValue) - : MAX_SIGNED_31_BIT_INT; + const currentTime = requestCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); - if (__DEV__) { - warning( - (changedBits & MAX_SIGNED_31_BIT_INT) === changedBits, - 'calculateChangedBits: Expected the return value to be a ' + - '31-bit integer. Instead received: %s', + const update = createUpdate(expirationTime); + update.payload = state => { + return { + firstContextUpdate: { + context, + value, changedBits, - ); + next: null, + }, + }; + }; + update.callback = callback; + + enqueueUpdate(fiber, update); + + scheduleWork(fiber, expirationTime); +} + +export function propagateRootContextChanges( + workInProgress: Fiber, + firstContextUpdate: RootContextUpdate, + renderExpirationTime: ExpirationTime, +): void { + let contextUpdate = firstContextUpdate; + do { + const context = contextUpdate.context; + const value = contextUpdate.value; + const changedBits = contextUpdate.changedBits; + if (isPrimaryRenderer) { + context._currentValue = value; + context._changedBits = changedBits; + } else { + context._currentValue2 = value; + context._changedBits2 = changedBits; } - return changedBits | 0; - } + propagateContextChange( + workInProgress, + context, + changedBits, + renderExpirationTime, + ); + contextUpdate = contextUpdate.next; + } while (contextUpdate !== null); } export function propagateContextChange( @@ -217,7 +250,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 140b75e3aa70c7..d8b4aa4957a033 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 22ee98db32e68d..937efa4bd5e507 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, + value: T, + changedBits: number, + callback: (() => mixed) | null, + ): 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, + firstContextUpdate: null, + }; + + // 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/__tests__/ReactContextUpdates-test.internal.js b/packages/react-reconciler/src/__tests__/ReactContextUpdates-test.internal.js new file mode 100644 index 00000000000000..6e18c02a38ccc6 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactContextUpdates-test.internal.js @@ -0,0 +1,310 @@ +/** + * 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('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']); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 85cf6b6fbdaf8f..95d78b8f9de6ea 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 f6c7cb67b627b1..a933095bedb346 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 bdbb55f9389fd3..8940f2b1f799ca 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -13,10 +13,57 @@ import type {ReactContext} from 'shared/ReactTypes'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; +import calculateChangedBits from 'shared/calculateChangedBits'; import ReactCurrentOwner from './ReactCurrentOwner'; +import ReactRootList from './ReactRootList'; -export function readContext( +function setContext( + context: ReactContext, + newValue: T, + userCallback: (() => mixed) | void | null, +): void { + const oldValue = context._globalValue; + context._globalValue = newValue; + + const changedBits = calculateChangedBits(context, oldValue, 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) { + cb(); + } + }; + } else { + // There are no mounted roots. Fire the callback and exit. + userCallback(); + 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) { + root.setContext(context, newValue, changedBits, wrappedCallback); + root = root.nextGlobalRoot; + } +} + +function readContext( context: ReactContext, observedBits: void | number | boolean, ): T { @@ -31,25 +78,26 @@ export function readContext( export function createContext( defaultValue: T, - calculateChangedBits: ?(a: T, b: T) => number, + calculateChangedBitsFn: ?(a: T, b: T) => number, ): ReactContext { - if (calculateChangedBits === undefined) { - calculateChangedBits = null; + if (calculateChangedBitsFn === undefined) { + calculateChangedBitsFn = null; } else { if (__DEV__) { warningWithoutStack( - calculateChangedBits === null || - typeof calculateChangedBits === 'function', + calculateChangedBitsFn === null || + typeof calculateChangedBitsFn === 'function', 'createContext: Expected the optional second argument to be a ' + 'function. Instead received: %s', - calculateChangedBits, + calculateChangedBitsFn, ); } } const context: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, - _calculateChangedBits: calculateChangedBits, + _calculateChangedBits: calculateChangedBitsFn, + _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 @@ -63,6 +111,7 @@ export function createContext( Provider: (null: any), Consumer: (null: any), unstable_read: (null: any), + unstable_set: (null: any), }; context.Provider = { @@ -71,6 +120,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 00000000000000..27c4a62d2ff019 --- /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, + value: T, + changedBits: number, + callback: (T => mixed) | null, + ): 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 8b179aff8422c8..ccedb93ee3a226 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 c662a285aa01f8..ac1f894ac09ddd 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, diff --git a/packages/shared/calculateChangedBits.js b/packages/shared/calculateChangedBits.js new file mode 100644 index 00000000000000..cf68cf6cfbf36c --- /dev/null +++ b/packages/shared/calculateChangedBits.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; + +import warning from 'shared/warning'; +import MAX_SIGNED_31_BIT_INT from 'shared/maxSigned31BitInt'; + +export default function calculateChangedBits( + context: ReactContext, + oldValue: T, + newValue: T, +) { + // Use Object.is to compare the new context value to the old value. Inlined + // Object.is polyfill. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + if ( + (oldValue === newValue && + (oldValue !== 0 || 1 / oldValue === 1 / (newValue: any))) || + (oldValue !== oldValue && newValue !== newValue) // eslint-disable-line no-self-compare + ) { + // No change + return 0; + } else { + const changedBits = + typeof context._calculateChangedBits === 'function' + ? context._calculateChangedBits(oldValue, newValue) + : MAX_SIGNED_31_BIT_INT; + + if (__DEV__) { + warning( + (changedBits & MAX_SIGNED_31_BIT_INT) === changedBits, + 'calculateChangedBits: Expected the return value to be a ' + + '31-bit integer. Instead received: %s', + changedBits, + ); + } + return changedBits | 0; + } +} diff --git a/packages/shared/maxSigned31BitInt.js b/packages/shared/maxSigned31BitInt.js new file mode 100644 index 00000000000000..2b6e167d0dfe79 --- /dev/null +++ b/packages/shared/maxSigned31BitInt.js @@ -0,0 +1,13 @@ +/** + * 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 + */ + +// Max 31 bit integer. The max integer size in V8 for 32-bit systems. +// Math.pow(2, 30) - 1 +// 0b111111111111111111111111111111 +export default 1073741823;