diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index fb71d185819639..4c5e30080c97a6 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 { markActualRenderTimeStarted, @@ -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( @@ -425,10 +426,10 @@ function pushHostRootContext(workInProgress) { pushTopLevelContextObject(workInProgress, root.context, false); } pushHostContainer(workInProgress, root.containerInfo); + pushRootContexts(workInProgress, renderExpirationTime); } function updateHostRoot(current, workInProgress, renderExpirationTime) { - pushHostRootContext(workInProgress); 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, @@ -446,6 +447,9 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) { null, renderExpirationTime, ); + // This must come *after* processing the update queue, in case any contexts + // were updated. + pushHostRootContext(workInProgress, renderExpirationTime); const nextState = workInProgress.memoizedState; // Caution: React DevTools currently depends on this property // being called "element". @@ -826,7 +830,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 +984,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/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 492809c02f4d53..8d8003da6a467f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -65,7 +65,7 @@ import { popContextProvider as popLegacyContextProvider, popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext'; -import {popProvider} from './ReactFiberNewContext'; +import {popProvider, popRootContexts} from './ReactFiberNewContext'; import { prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, @@ -325,6 +325,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 e8f8e1b1e22e3d..95d9ab3a518e52 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -19,16 +19,33 @@ export type ContextDependency = { }; import warningWithoutStack from 'shared/warningWithoutStack'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; import {isPrimaryRenderer} from './ReactFiberHostConfig'; +import { + getCurrentlyRenderingRoot, + 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, + enqueueCapturedUpdate, + processUpdateQueue, +} from './ReactUpdateQueue'; import invariant from 'shared/invariant'; import warning from 'shared/warning'; import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; + +const {ReactRootList} = ReactSharedInternals; + +const hasProviderCursor: StackCursor = createCursor(false); const valueCursor: StackCursor = createCursor(null); const changedBitsCursor: StackCursor = createCursor(0); @@ -39,6 +56,7 @@ if (__DEV__) { } let currentlyRenderingFiber: Fiber | null = null; +let currentlyRenderingExpirationTime: ExpirationTime = NoWork; let lastContextDependency: ContextDependency | null = null; let lastContextWithAllBitsObserved: ReactContext | null = null; @@ -46,6 +64,7 @@ export function resetContextDependences(): void { // This is called right before React yields execution, to ensure `readContext` // cannot be called outside the render phase. currentlyRenderingFiber = null; + currentlyRenderingExpirationTime = NoWork; lastContextDependency = null; lastContextWithAllBitsObserved = null; } @@ -54,6 +73,7 @@ export function pushProvider(providerFiber: Fiber, changedBits: number): void { const context: ReactContext = providerFiber.type._context; if (isPrimaryRenderer) { + push(hasProviderCursor, true, providerFiber); push(changedBitsCursor, context._changedBits, providerFiber); push(valueCursor, context._currentValue, providerFiber); @@ -70,6 +90,7 @@ export function pushProvider(providerFiber: Fiber, changedBits: number): void { context._currentRenderer = rendererSigil; } } else { + push(hasProviderCursor, true, providerFiber); push(changedBitsCursor, context._changedBits2, providerFiber); push(valueCursor, context._currentValue2, providerFiber); @@ -94,6 +115,7 @@ export function popProvider(providerFiber: Fiber): void { pop(valueCursor, providerFiber); pop(changedBitsCursor, providerFiber); + pop(hasProviderCursor, providerFiber); const context: ReactContext = providerFiber.type._context; if (isPrimaryRenderer) { @@ -105,10 +127,211 @@ export function popProvider(providerFiber: Fiber): void { } } +type RootContextProvider = { + context: ReactContext, + value: T, + changedBits: number, + next: RootContextProvider | null, +}; + +function pushSingleRootContext( + workInProgress: Fiber, + provider: RootContextProvider, + renderExpirationTime: ExpirationTime, +) { + const context = provider.context; + const nextValue = provider.value; + const changedBits = provider.changedBits; + + if (isPrimaryRenderer) { + context._currentValue = nextValue; + context._changedBits = changedBits; + context._hasProvider = true; + } else { + context._currentValue2 = nextValue; + context._changedBits2 = changedBits; + context._hasProvider2 = true; + } + + if (changedBits !== 0) { + propagateContextChange( + workInProgress, + context, + changedBits, + renderExpirationTime, + ); + return true; + } else { + return false; + } +} + +export function pushRootContexts( + workInProgress: Fiber, + renderExpirationTime: ExpirationTime, +): boolean { + const state = workInProgress.memoizedState; + if (state !== null) { + let someContextDidChange = false; + let provider = state.firstProvider; + while (provider !== null) { + const didChange = pushSingleRootContext( + workInProgress, + provider, + renderExpirationTime, + ); + if (didChange) { + someContextDidChange = true; + } + provider = provider.next; + } + return someContextDidChange; + } + return false; +} + +export function popRootContexts(workInProgress: Fiber): void { + const state = workInProgress.memoizedState; + let provider = state.firstProvider; + while (provider !== null) { + const context = provider.context; + const globalValue = context._globalValue; + if (isPrimaryRenderer) { + context._currentValue = globalValue; + context._changedBits = 0; + context._hasProvider = false; + } else { + context._currentValue2 = globalValue; + context._changedBits2 = 0; + context._hasProvider2 = false; + } + provider = provider.next; + } +} + +export function setRootContext( + fiber: Fiber, + context: ReactContext, + newValueForThisContext: T, + callback: (() => mixed) | null, +) { + const currentTime = requestCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); + + const update = createUpdate(expirationTime); + update.payload = state => { + // Copy the previous list of context providers. While copying, check if a + // provider matches the updated context object. If so, update its value. + let firstNewProvider = null; + let prevNewProvider = null; + let oldProvider = state.firstProvider; + while (oldProvider !== null) { + const previousValue = oldProvider.value; + + // Check if this provider matches the context we're updating. + let nextValue; + let changedBits; + if (oldProvider.context === context) { + nextValue = newValueForThisContext; + changedBits = calculateChangedBits(context, previousValue, nextValue); + } else { + nextValue = oldProvider.value; + changedBits = oldProvider.changedBits; + } + + const newProvider: RootContextProvider = { + context, + value: nextValue, + changedBits, + next: null, + }; + + if (prevNewProvider !== null) { + prevNewProvider.next = newProvider; + } else { + firstNewProvider = newProvider; + } + + prevNewProvider = newProvider; + oldProvider = oldProvider.next; + } + return {firstProvider: firstNewProvider}; + }; + + update.callback = callback; + + enqueueUpdate(fiber, update); + + scheduleWork(fiber, expirationTime); +} + +function lazilyInjectRootContextProvider(context, renderExpirationTime) { + // This is called the first time a stateful context is read without a + // provider. We need to inject a special type of provider at the root that + // tracks updates from the global context object. + const root = getCurrentlyRenderingRoot(); + + // Ensure this root is mounted in the global list of roots. + if (!root.isMounted) { + root.isMounted = true; + const lastRoot = ReactRootList.last; + if (lastRoot !== null) { + lastRoot.nextGlobalRoot = root; + } else { + ReactRootList.first = root; + } + ReactRootList.last = root; + } + + const current = root.current; + const workInProgress = current.alternate; + invariant( + workInProgress !== null, + 'Expected the root to have a work-in-progress. This error is likely ' + + 'caused by a bug in React. Please file an issue.', + ); + + // Create a new root context provider and prepend it to the list. (The order + // of providers doesn't matter.) The initial value is whatever happens to be + // the current global value. All subsequent reads will read from the provider + // instead of the global value, for consistency. + const initialValue = context._globalValue; + const injectedProvider: RootContextProvider = { + context, + value: initialValue, + changedBits: 0, + next: null, + }; + + // Create a root update and process it immediately. We do this so that the + // update queue's `baseState` is correctly updated, since that's easy to + // mess up. + const update = createUpdate(renderExpirationTime); + update.payload = state => { + injectedProvider.next = state.firstProvider; + return {firstProvider: injectedProvider}; + }; + enqueueCapturedUpdate(workInProgress, update); + const updateQueue = workInProgress.updateQueue; + if (updateQueue !== null) { + processUpdateQueue( + workInProgress, + updateQueue, + null, + null, + renderExpirationTime, + ); + } + + // Push the lazily injected provider onto the "stack" as if it had been pushed + // during the begin phase of the root. + pushSingleRootContext(workInProgress, injectedProvider, renderExpirationTime); +} + 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. @@ -257,6 +480,7 @@ export function prepareToReadContext( currentlyRenderingFiber = workInProgress; lastContextDependency = null; lastContextWithAllBitsObserved = null; + currentlyRenderingExpirationTime = renderExpirationTime; const firstContextDependency = workInProgress.firstContextDependency; if (firstContextDependency !== null) { @@ -322,6 +546,27 @@ export function readContext( next: null, }; + // If this context accepts updates, make sure it has a provider. + if (context._isStateful) { + if (isPrimaryRenderer) { + if (!context._hasProvider) { + // No provider found. Inject a provider at the root to listen for + // global updates. + lazilyInjectRootContextProvider( + context, + currentlyRenderingExpirationTime, + ); + } + } else { + if (!context._hasProvider2) { + lazilyInjectRootContextProvider( + context, + currentlyRenderingExpirationTime, + ); + } + } + } + if (lastContextDependency === null) { invariant( currentlyRenderingFiber !== null, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 140b75e3aa70c7..42526ee37613c2 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -163,6 +163,26 @@ export function updateContainerAtExpirationTime( } } + let wrappedCallback; + if (container.isMounted && element === null) { + // Assume this is an unmount and mark the root for clean-up from the + // global list. + // TODO: Need an explicit API for unmounting. + if (callback !== null && callback !== undefined) { + const cb = callback; + wrappedCallback = function() { + container.isMounted = false; + return cb.call(this); + }; + } else { + wrappedCallback = () => { + container.isMounted = false; + }; + } + } else { + wrappedCallback = callback; + } + const context = getContextForSubtree(parentComponent); if (container.context === null) { container.context = context; @@ -170,7 +190,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..38d26677133838 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -7,6 +7,7 @@ * @flow */ +import type {ReactContext} from 'shared/ReactTypes'; import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; @@ -15,6 +16,7 @@ import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; +import {setRootContext} from './ReactFiberNewContext'; // TODO: This should be lifted into the renderer. export type Batch = { @@ -73,6 +75,14 @@ export type FiberRoot = { firstBatch: Batch | null, // Linked-list of roots nextScheduledRoot: FiberRoot | null, + + nextGlobalRoot: FiberRoot | null, + isMounted: boolean, + setContext( + context: ReactContext, + value: T, + callback: (() => mixed) | null, + ): void, }; export function createFiberRoot( @@ -106,7 +116,15 @@ export function createFiberRoot( expirationTime: NoWork, firstBatch: null, nextScheduledRoot: null, + + nextGlobalRoot: null, + isMounted: false, + setContext: (setRootContext.bind(null, uninitializedFiber): any), }; uninitializedFiber.stateNode = root; + uninitializedFiber.memoizedState = { + element: null, + firstProvider: null, + }; return root; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 325522b2c4b4e3..b6e0bc8a1d1f25 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -109,7 +109,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 { checkActualRenderTimeStackEmpty, @@ -292,6 +296,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { case HostRoot: popHostContainer(failedUnitOfWork); popTopLevelLegacyContextObject(failedUnitOfWork); + popRootContexts(failedUnitOfWork); break; case HostComponent: popHostContext(failedUnitOfWork); @@ -1534,6 +1539,15 @@ function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) { } } +function getCurrentlyRenderingRoot(): FiberRoot { + invariant( + nextRoot !== null, + 'Expected a currently rendering root. This is likely a bug in React. ' + + 'Please file an issue.', + ); + return nextRoot; +} + function deferredUpdates(fn: () => A): A { const currentTime = requestCurrentTime(); const previousExpirationContext = expirationContext; @@ -2243,6 +2257,7 @@ export { markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, scheduleWork, + getCurrentlyRenderingRoot, requestWork, flushRoot, batchedUpdates, diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 489c6694737168..421e1682729163 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -51,7 +51,7 @@ import { popContextProvider as popLegacyContextProvider, popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext'; -import {popProvider} from './ReactFiberNewContext'; +import {popProvider, popRootContexts} from './ReactFiberNewContext'; import {recordElapsedActualRenderTime} from './ReactProfilerTimer'; import { renderDidSuspend, @@ -399,6 +399,7 @@ function unwindWork( case HostRoot: { popHostContainer(workInProgress); popTopLevelLegacyContextObject(workInProgress); + popRootContexts(workInProgress); const effectTag = workInProgress.effectTag; invariant( (effectTag & DidCapture) === NoEffect, @@ -446,6 +447,7 @@ function unwindInterruptedWork(interruptedWork: Fiber) { case HostRoot: { popHostContainer(interruptedWork); popTopLevelLegacyContextObject(interruptedWork); + popRootContexts(interruptedWork); break; } case HostComponent: { diff --git a/packages/react-reconciler/src/__tests__/ReactGlobalState-test.internal.js b/packages/react-reconciler/src/__tests__/ReactGlobalState-test.internal.js new file mode 100644 index 00000000000000..8442bcead3715c --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactGlobalState-test.internal.js @@ -0,0 +1,242 @@ +/** + * 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('ReactGlobalState', () => { + 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.createGlobalState('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.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.createGlobalState('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.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.createGlobalState('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'), + ]); + + const callback = () => { + ReactNoop.yield('Did call callback'); + }; + + // Update the global state. Both roots should update. + ThemeState.set('dark', 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('unmounts a root that reads from global state', () => { + const ThemeState = React.createGlobalState('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.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.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.createGlobalState('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.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')]); + }); +}); diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 15e10114dcd96d..e45a7322f3ee43 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -24,7 +24,7 @@ import { cloneElement, isValidElement, } from './ReactElement'; -import {createContext} from './ReactContext'; +import {createContext, createGlobalState} from './ReactContext'; import forwardRef from './forwardRef'; import { createElementWithValidation, @@ -68,4 +68,7 @@ if (enableSuspense) { React.Placeholder = REACT_PLACEHOLDER_TYPE; } +// TODO: Put behind feature flag? +React.createGlobalState = createGlobalState; + export default React; diff --git a/packages/react/src/ReactContext.js b/packages/react/src/ReactContext.js index bdbb55f9389fd3..dcb41a3aeb405b 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -15,8 +15,64 @@ import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; import ReactCurrentOwner from './ReactCurrentOwner'; +import ReactRootList from './ReactRootList'; -export function readContext( +function setContext( + context: ReactContext, + value: T, + callback: (() => mixed) | void | null, +): void { + context._globalValue = value; + + if (callback !== null && callback !== undefined) { + // Create an immutable binding to satisfy the gods of Flow + const userCallback = callback; + let prevRoot = null; + let root = ReactRootList.first; + if (root !== null) { + // Use reference counting to wait until all roots have updated before + // calling the callback. + let numRootsThatNeedUpdate = 0; + const wrappedCallback = () => { + numRootsThatNeedUpdate -= 1; + if (numRootsThatNeedUpdate === 0) { + userCallback(); + } + }; + do { + if (root.isMounted) { + numRootsThatNeedUpdate += 1; + root.setContext(context, value, wrappedCallback); + } else { + // This root is no longer mounted. Remove it from the global list. + const next = root.nextGlobalRoot; + if (prevRoot !== null) { + prevRoot.nextGlobalRoot = next; + } else { + ReactRootList.first = next; + } + if (next === null) { + ReactRootList.last = next; + } + } + root = root.nextGlobalRoot; + } while (root !== null); + } else { + // There are no mounted roots. Fire the callback and exit. + userCallback(); + return; + } + } else { + // Schedule an update on each root. + let root = ReactRootList.first; + while (root !== null) { + root.setContext(context, value, null); + root = root.nextGlobalRoot; + } + } +} + +function readContext( context: ReactContext, observedBits: void | number | boolean, ): T { @@ -50,6 +106,8 @@ export function createContext( const context: ReactContext = { $$typeof: REACT_CONTEXT_TYPE, _calculateChangedBits: calculateChangedBits, + _globalValue: defaultValue, + _isStateful: false, // 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 @@ -59,10 +117,13 @@ export function createContext( _currentValue2: defaultValue, _changedBits: 0, _changedBits2: 0, + _hasProvider: false, + _hasProvider2: false, // These are circular Provider: (null: any), Consumer: (null: any), unstable_read: (null: any), + set: null, }; context.Provider = { @@ -79,3 +140,13 @@ export function createContext( return context; } + +export function createGlobalState( + initialValue: T, + calculateChangedBits: ?(a: T, b: T) => number, +) { + const context = createContext(initialValue, calculateChangedBits); + context._isStateful = true; + context.set = setContext.bind(null, context); + return context; +} diff --git a/packages/react/src/ReactRootList.js b/packages/react/src/ReactRootList.js new file mode 100644 index 00000000000000..40f39b10771008 --- /dev/null +++ b/packages/react/src/ReactRootList.js @@ -0,0 +1,27 @@ +/** + * 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, + callback: (T => mixed) | null, + ): void, + 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..7e89e56b368d14 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -79,14 +79,20 @@ export type ReactContext = { $$typeof: Symbol | number, Consumer: ReactContext, Provider: ReactProviderType, + unstable_read: () => T, + set: ((value: T, callback: (() => mixed) | void | null) => void) | null, _calculateChangedBits: ((a: T, b: T) => number) | null, + _globalValue: T, + _isStateful: boolean, _currentValue: T, _currentValue2: T, _changedBits: number, _changedBits2: number, + _hasProvider: boolean, + _hasProvider2: boolean, // DEV only _currentRenderer?: Object | null,