From 87ae211ccd8d61796cfdef138d1e12fb7a74f85d Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 24 Jan 2018 19:36:22 -0800 Subject: [PATCH] New context API (#11818) * New context API Introduces a declarative context API that propagates updates even when shouldComponentUpdate returns false. * Fuzz tester for context * Use ReactElement for provider and consumer children * Unify more branches in createFiberFromElement * Compare context values using Object.is Same semantics as PureComponent/shallowEqual. * Add support for Provider and Consumer to server-side renderer * Store providers on global stack Rather than using a linked list stored on the context type. The global stack can be reset in case of an interruption or error, whereas with the linked list implementation, you'd need to keep track of every context type. * Put new context API behind a feature flag We'll enable this in www only for now. * Store nearest provider on context object * Handle reentrancy in server renderer Context stack should be per server renderer instance. * Bailout of consumer updates using bitmask The context type defines an optional function that compares two context values, returning a bitfield. A consumer may specify the bits it needs for rendering. If a provider's context changes, and the consumer's bits do not intersect with the changed bits, we can skip the consumer. This is similar to how selectors are used in Redux but fast enough to do while scanning the tree. The only user code involved is the function that computes the changed bits. But that's only called once per provider update, not for every consumer. * Store current value and changed bits on context object There are fewer providers than consumers, so better to do this work at the provider. * Use maximum of 31 bits for bitmask This is the largest integer size in V8 on 32-bit systems. Warn in development if too large a number is used. * ProviderComponent -> ContextProvider, ConsumerComponent -> ContextConsumer * Inline Object.is * Warn if multiple renderers concurrently render the same context provider Let's see if we can get away with not supporting this for now. If it turns out that it's needed, we can fall back to backtracking the fiber return path. * Nits that came up during review --- package.json | 1 + ... => ReactServerRendering-test.internal.js} | 96 +++ .../src/server/ReactPartialRenderer.js | 124 ++- packages/react-reconciler/src/ReactFiber.js | 153 ++-- .../src/ReactFiberBeginWork.js | 262 +++++- .../src/ReactFiberCompleteWork.js | 17 +- .../src/ReactFiberExpirationTime.js | 4 +- .../src/ReactFiberNewContext.js | 70 ++ .../src/ReactFiberScheduler.js | 11 +- .../ReactNewContext-test.internal.js | 784 ++++++++++++++++++ .../react-reconciler/src/maxSigned31BitInt.js | 13 + packages/react/src/React.js | 6 + packages/shared/ReactContext.js | 91 ++ packages/shared/ReactFeatureFlags.js | 2 + packages/shared/ReactSymbols.js | 6 + packages/shared/ReactTypeOfWork.js | 18 +- packages/shared/ReactTypes.js | 48 +- .../forks/ReactFeatureFlags.native-fabric.js | 1 + .../shared/forks/ReactFeatureFlags.native.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + yarn.lock | 6 + 21 files changed, 1607 insertions(+), 108 deletions(-) rename packages/react-dom/src/__tests__/{ReactServerRendering-test.js => ReactServerRendering-test.internal.js} (85%) create mode 100644 packages/react-reconciler/src/ReactFiberNewContext.js create mode 100644 packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js create mode 100644 packages/react-reconciler/src/maxSigned31BitInt.js create mode 100644 packages/shared/ReactContext.js diff --git a/package.json b/package.json index 2cf5e65904..e12b9ccd97 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "platform": "^1.1.0", "prettier": "1.8.1", "prop-types": "^15.6.0", + "random-seed": "^0.3.0", "rimraf": "^2.6.1", "rollup": "^0.52.1", "rollup-plugin-babel": "^3.0.1", diff --git a/packages/react-dom/src/__tests__/ReactServerRendering-test.js b/packages/react-dom/src/__tests__/ReactServerRendering-test.internal.js similarity index 85% rename from packages/react-dom/src/__tests__/ReactServerRendering-test.js rename to packages/react-dom/src/__tests__/ReactServerRendering-test.internal.js index f37fa2c15c..ad351544f1 100644 --- a/packages/react-dom/src/__tests__/ReactServerRendering-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRendering-test.internal.js @@ -14,6 +14,7 @@ let React; let ReactCallReturn; let ReactDOMServer; let PropTypes; +let ReactFeatureFlags; function normalizeCodeLocInfo(str) { return str && str.replace(/\(at .+?:\d+\)/g, '(at **)'); @@ -22,6 +23,8 @@ function normalizeCodeLocInfo(str) { describe('ReactDOMServer', () => { beforeEach(() => { jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableNewContextAPI = true; React = require('react'); ReactCallReturn = require('react-call-return'); PropTypes = require('prop-types'); @@ -384,6 +387,99 @@ describe('ReactDOMServer', () => { expect(markup).toContain('hello, world'); }); + it('renders with new context API', () => { + const Context = React.unstable_createContext(0); + + function Provider(props) { + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + return Context.consume(value => { + return 'Result: ' + value; + }); + } + + const Indirection = React.Fragment; + + function App(props) { + return ( + + + + + + + + + + + + + + + ); + } + + const markup = ReactDOMServer.renderToString(); + // Extract the numbers rendered by the consumers + const results = markup.match(/\d+/g).map(Number); + expect(results).toEqual([2, 1, 3, 1]); + }); + + it('renders context API, reentrancy', () => { + const Context = React.unstable_createContext(0); + + function Provider(props) { + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + return Context.consume(value => { + return 'Result: ' + value; + }); + } + + let reentrantMarkup; + function Reentrant() { + reentrantMarkup = ReactDOMServer.renderToString( + , + ); + return null; + } + + const Indirection = React.Fragment; + + function App(props) { + return ( + + {props.reentrant && } + + + + + + + + + + + + + + ); + } + + const markup = ReactDOMServer.renderToString( + , + ); + // Extract the numbers rendered by the consumers + const results = markup.match(/\d+/g).map(Number); + const reentrantResults = reentrantMarkup.match(/\d+/g).map(Number); + expect(results).toEqual([2, 1, 3, 1]); + expect(reentrantResults).toEqual([2, 1, 3, 1]); + }); + it('renders components with different batching strategies', () => { class StaticComponent extends React.Component { render() { diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 339411596b..b07a9b160d 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -8,6 +8,11 @@ */ import type {ReactElement} from 'shared/ReactElementType'; +import type { + ReactProvider, + ReactConsumer, + ReactContext, +} from 'shared/ReactTypes'; import React from 'react'; import emptyFunction from 'fbjs/lib/emptyFunction'; @@ -25,6 +30,8 @@ import { REACT_CALL_TYPE, REACT_RETURN_TYPE, REACT_PORTAL_TYPE, + REACT_PROVIDER_TYPE, + REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; import { @@ -192,7 +199,7 @@ function warnNoop( const constructor = publicInstance.constructor; const componentName = (constructor && getComponentName(constructor)) || 'ReactClass'; - const warningKey = `${componentName}.${callerName}`; + const warningKey = componentName + '.' + callerName; if (didWarnAboutNoopUpdateForComponent[warningKey]) { return; } @@ -603,6 +610,7 @@ function resolve( } type Frame = { + type: mixed, domNamespace: string, children: FlatReactChildren, childIndex: number, @@ -622,12 +630,16 @@ class ReactDOMServerRenderer { previousWasTextNode: boolean; makeStaticMarkup: boolean; + providerStack: Array>; + providerIndex: number; + constructor(children: mixed, makeStaticMarkup: boolean) { const flatChildren = flattenTopLevelChildren(children); const topFrame: Frame = { // Assume all trees start in the HTML namespace (not totally true, but // this is what we did historically) + type: null, domNamespace: Namespaces.html, children: flatChildren, childIndex: 0, @@ -642,6 +654,39 @@ class ReactDOMServerRenderer { this.currentSelectValue = null; this.previousWasTextNode = false; this.makeStaticMarkup = makeStaticMarkup; + + // Context (new API) + this.providerStack = []; // Stack of provider objects + this.providerIndex = -1; + } + + pushProvider(provider: ReactProvider): void { + this.providerIndex += 1; + this.providerStack[this.providerIndex] = provider; + const context: ReactContext = provider.type.context; + context.currentValue = provider.props.value; + } + + popProvider(provider: ReactProvider): void { + if (__DEV__) { + warning( + this.providerIndex > -1 && + provider === this.providerStack[this.providerIndex], + 'Unexpected pop.', + ); + } + this.providerStack[this.providerIndex] = null; + this.providerIndex -= 1; + const context: ReactContext = provider.type.context; + if (this.providerIndex < 0) { + context.currentValue = context.defaultValue; + } else { + // We assume this type is correct because of the index check above. + const previousProvider: ReactProvider = (this.providerStack[ + this.providerIndex + ]: any); + context.currentValue = previousProvider.props.value; + } } read(bytes: number): string | null { @@ -663,8 +708,15 @@ class ReactDOMServerRenderer { this.previousWasTextNode = false; } this.stack.pop(); - if (frame.tag === 'select') { + if (frame.type === 'select') { this.currentSelectValue = null; + } else if ( + frame.type != null && + frame.type.type != null && + frame.type.type.$$typeof === REACT_PROVIDER_TYPE + ) { + const provider: ReactProvider = (frame.type: any); + this.popProvider(provider); } continue; } @@ -723,6 +775,7 @@ class ReactDOMServerRenderer { } const nextChildren = toArray(nextChild); const frame: Frame = { + type: null, domNamespace: parentNamespace, children: nextChildren, childIndex: 0, @@ -738,12 +791,18 @@ class ReactDOMServerRenderer { // Safe because we just checked it's an element. const nextElement = ((nextChild: any): ReactElement); const elementType = nextElement.type; + + if (typeof elementType === 'string') { + return this.renderDOM(nextElement, context, parentNamespace); + } + switch (elementType) { - case REACT_FRAGMENT_TYPE: + case REACT_FRAGMENT_TYPE: { const nextChildren = toArray( ((nextChild: any): ReactElement).props.children, ); const frame: Frame = { + type: null, domNamespace: parentNamespace, children: nextChildren, childIndex: 0, @@ -755,6 +814,7 @@ class ReactDOMServerRenderer { } this.stack.push(frame); return ''; + } case REACT_CALL_TYPE: case REACT_RETURN_TYPE: invariant( @@ -764,8 +824,62 @@ class ReactDOMServerRenderer { ); // eslint-disable-next-line-no-fallthrough default: - return this.renderDOM(nextElement, context, parentNamespace); + break; } + if (typeof elementType === 'object' && elementType !== null) { + switch (elementType.$$typeof) { + case REACT_PROVIDER_TYPE: { + const provider: ReactProvider = (nextChild: any); + const nextProps = provider.props; + const nextChildren = toArray(nextProps.children); + const frame: Frame = { + type: provider, + domNamespace: parentNamespace, + children: nextChildren, + childIndex: 0, + context: context, + footer: '', + }; + if (__DEV__) { + ((frame: any): FrameDev).debugElementStack = []; + } + + this.pushProvider(provider); + + this.stack.push(frame); + return ''; + } + case REACT_CONTEXT_TYPE: { + const consumer: ReactConsumer = (nextChild: any); + const nextProps = consumer.props; + const nextValue = consumer.type.currentValue; + + const nextChildren = toArray(nextProps.render(nextValue)); + const frame: Frame = { + type: nextChild, + domNamespace: parentNamespace, + children: nextChildren, + childIndex: 0, + context: context, + footer: '', + }; + if (__DEV__) { + ((frame: any): FrameDev).debugElementStack = []; + } + this.stack.push(frame); + return ''; + } + default: + break; + } + } + invariant( + false, + 'Element type is invalid: expected a string (for built-in ' + + 'components) or a class/function (for composite components) ' + + 'but got: %s.', + elementType == null ? elementType : typeof elementType, + ); } } @@ -1052,7 +1166,7 @@ class ReactDOMServerRenderer { } const frame = { domNamespace: getChildNamespace(parentNamespace, element.type), - tag, + type: tag, children, childIndex: 0, context: context, diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index d36433099c..fb6ba80555 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -7,7 +7,7 @@ */ import type {ReactElement, Source} from 'shared/ReactElementType'; -import type {ReactFragment, ReactPortal} from 'shared/ReactTypes'; +import type {ReactPortal} from 'shared/ReactTypes'; import type {TypeOfWork} from 'shared/ReactTypeOfWork'; import type {TypeOfInternalContext} from './ReactTypeOfInternalContext'; import type {TypeOfSideEffect} from 'shared/ReactTypeOfSideEffect'; @@ -27,6 +27,8 @@ import { ReturnComponent, Fragment, Mode, + ContextProvider, + ContextConsumer, } from 'shared/ReactTypeOfWork'; import getComponentName from 'shared/getComponentName'; @@ -41,6 +43,8 @@ import { REACT_RETURN_TYPE, REACT_CALL_TYPE, REACT_STRICT_MODE_TYPE, + REACT_PROVIDER_TYPE, + REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; let hasBadMapPolyfill; @@ -316,20 +320,13 @@ export function createFiberFromElement( let fiber; const type = element.type; const key = element.key; - const pendingProps = element.props; + let pendingProps = element.props; + + let fiberTag; if (typeof type === 'function') { - fiber = shouldConstruct(type) - ? createFiber(ClassComponent, pendingProps, key, internalContextTag) - : createFiber( - IndeterminateComponent, - pendingProps, - key, - internalContextTag, - ); - fiber.type = type; + fiberTag = shouldConstruct(type) ? ClassComponent : IndeterminateComponent; } else if (typeof type === 'string') { - fiber = createFiber(HostComponent, pendingProps, key, internalContextTag); - fiber.type = type; + fiberTag = HostComponent; } else { switch (type) { case REACT_FRAGMENT_TYPE: @@ -340,90 +337,92 @@ export function createFiberFromElement( key, ); case REACT_STRICT_MODE_TYPE: - fiber = createFiber( - Mode, - pendingProps, - key, - internalContextTag | StrictMode, - ); - fiber.type = REACT_STRICT_MODE_TYPE; + fiberTag = Mode; + internalContextTag |= StrictMode; break; case REACT_CALL_TYPE: - fiber = createFiber( - CallComponent, - pendingProps, - key, - internalContextTag, - ); - fiber.type = REACT_CALL_TYPE; + fiberTag = CallComponent; break; case REACT_RETURN_TYPE: - fiber = createFiber( - ReturnComponent, - pendingProps, - key, - internalContextTag, - ); - fiber.type = REACT_RETURN_TYPE; + fiberTag = ReturnComponent; break; default: { - if ( - typeof type === 'object' && - type !== null && - typeof type.tag === 'number' - ) { - // Currently assumed to be a continuation and therefore is a - // fiber already. - // TODO: The yield system is currently broken for updates in some - // cases. The reified yield stores a fiber, but we don't know which - // fiber that is; the current or a workInProgress? When the - // continuation gets rendered here we don't know if we can reuse that - // fiber or if we need to clone it. There is probably a clever way to - // restructure this. - fiber = ((type: any): Fiber); - fiber.pendingProps = pendingProps; - } else { - let info = ''; - if (__DEV__) { - if ( - type === undefined || - (typeof type === 'object' && - type !== null && - Object.keys(type).length === 0) - ) { - info += - ' You likely forgot to export your component from the file ' + - "it's defined in, or you might have mixed up default and " + - 'named imports.'; - } - const ownerName = owner ? getComponentName(owner) : null; - if (ownerName) { - info += '\n\nCheck the render method of `' + ownerName + '`.'; - } + if (typeof type === 'object' && type !== null) { + switch (type.$$typeof) { + case REACT_PROVIDER_TYPE: + fiberTag = ContextProvider; + break; + case REACT_CONTEXT_TYPE: + // This is a consumer + fiberTag = ContextConsumer; + break; + default: + if (typeof type.tag === 'number') { + // Currently assumed to be a continuation and therefore is a + // fiber already. + // TODO: The yield system is currently broken for updates in + // some cases. The reified yield stores a fiber, but we don't + // know which fiber that is; the current or a workInProgress? + // When the continuation gets rendered here we don't know if we + // can reuse that fiber or if we need to clone it. There is + // probably a clever way to restructure this. + fiber = ((type: any): Fiber); + fiber.pendingProps = pendingProps; + fiber.expirationTime = expirationTime; + return fiber; + } else { + throwOnInvalidElementType(type, owner); + } + break; } - invariant( - false, - 'Element type is invalid: expected a string (for built-in ' + - 'components) or a class/function (for composite components) ' + - 'but got: %s.%s', - type == null ? type : typeof type, - info, - ); + } else { + throwOnInvalidElementType(type, owner); } } } } + fiber = createFiber(fiberTag, pendingProps, key, internalContextTag); + fiber.type = type; + fiber.expirationTime = expirationTime; + if (__DEV__) { fiber._debugSource = element._source; fiber._debugOwner = element._owner; } - fiber.expirationTime = expirationTime; - return fiber; } +function throwOnInvalidElementType(type, owner) { + let info = ''; + if (__DEV__) { + if ( + type === undefined || + (typeof type === 'object' && + type !== null && + Object.keys(type).length === 0) + ) { + info += + ' You likely forgot to export your component from the file ' + + "it's defined in, or you might have mixed up default and " + + 'named imports.'; + } + const ownerName = owner ? getComponentName(owner) : null; + if (ownerName) { + info += '\n\nCheck the render method of `' + ownerName + '`.'; + } + } + invariant( + false, + 'Element type is invalid: expected a string (for built-in ' + + 'components) or a class/function (for composite components) ' + + 'but got: %s.%s', + type == null ? type : typeof type, + info, + ); +} + export function createFiberFromFragment( elements: ReactFragment, internalContextTag: TypeOfInternalContext, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index d0569a6b4a..ab616e4d34 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -8,12 +8,14 @@ */ import type {HostConfig} from 'react-reconciler'; +import type {ReactProviderType, ReactContext} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {HostContext} from './ReactFiberHostContext'; import type {HydrationContext} from './ReactFiberHydrationContext'; import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import {enableNewContextAPI} from 'shared/ReactFeatureFlags'; import { IndeterminateComponent, FunctionalComponent, @@ -27,6 +29,8 @@ import { ReturnComponent, Fragment, Mode, + ContextProvider, + ContextConsumer, } from 'shared/ReactTypeOfWork'; import { PerformedWork, @@ -53,13 +57,15 @@ import {processUpdateQueue} from './ReactFiberUpdateQueue'; import { getMaskedContext, getUnmaskedContext, - hasContextChanged, - pushContextProvider, + hasContextChanged as hasLegacyContextChanged, + pushContextProvider as pushLegacyContextProvider, pushTopLevelContextObject, invalidateContextProvider, } from './ReactFiberContext'; +import {pushProvider} from './ReactFiberNewContext'; import {NoWork, Never} from './ReactFiberExpirationTime'; import {AsyncUpdates} from './ReactTypeOfInternalContext'; +import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; let didWarnAboutBadClass; let didWarnAboutGetDerivedStateOnFunctionalComponent; @@ -147,13 +153,10 @@ export default function( function updateFragment(current, workInProgress) { const nextChildren = workInProgress.pendingProps; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. - } else if ( - nextChildren === null || - workInProgress.memoizedProps === nextChildren - ) { + } else if (workInProgress.memoizedProps === nextChildren) { return bailoutOnAlreadyFinishedWork(current, workInProgress); } reconcileChildren(current, workInProgress, nextChildren); @@ -163,7 +166,7 @@ export default function( function updateMode(current, workInProgress) { const nextChildren = workInProgress.pendingProps.children; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. } else if ( @@ -189,7 +192,7 @@ export default function( const fn = workInProgress.type; const nextProps = workInProgress.pendingProps; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. } else { @@ -228,7 +231,7 @@ export default function( // Push context providers early to prevent context stack mismatches. // During mounting we don't know the child context yet as the instance doesn't exist. // We will invalidate the child context in finishClassComponent() right after rendering. - const hasContext = pushContextProvider(workInProgress); + const hasContext = pushLegacyContextProvider(workInProgress); let shouldUpdate; if (current === null) { @@ -397,7 +400,7 @@ export default function( const nextProps = workInProgress.pendingProps; const prevProps = current !== null ? current.memoizedProps : null; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. } else if (memoizedProps === nextProps) { @@ -492,7 +495,8 @@ export default function( if ( typeof value === 'object' && value !== null && - typeof value.render === 'function' + typeof value.render === 'function' && + value.$$typeof === undefined ) { const Component = workInProgress.type; @@ -521,7 +525,7 @@ export default function( // Push context providers early to prevent context stack mismatches. // During mounting we don't know the child context yet as the instance doesn't exist. // We will invalidate the child context in finishClassComponent() right after rendering. - const hasContext = pushContextProvider(workInProgress); + const hasContext = pushLegacyContextProvider(workInProgress); adoptClassInstance(workInProgress, value); mountClassInstance(workInProgress, renderExpirationTime); return finishClassComponent(current, workInProgress, true, hasContext); @@ -587,7 +591,7 @@ export default function( function updateCallComponent(current, workInProgress, renderExpirationTime) { let nextProps = workInProgress.pendingProps; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. } else if (workInProgress.memoizedProps === nextProps) { @@ -630,7 +634,7 @@ export default function( ) { pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo); const nextChildren = workInProgress.pendingProps; - if (hasContextChanged()) { + if (hasLegacyContextChanged()) { // Normally we can bail out on props equality but if context has changed // we don't do the bailout and we have to reuse existing props instead. } else if (workInProgress.memoizedProps === nextChildren) { @@ -657,6 +661,215 @@ export default function( return workInProgress.child; } + function propagateContextChange( + workInProgress: Fiber, + context: ReactContext, + changedBits: number, + renderExpirationTime: ExpirationTime, + ): void { + if (enableNewContextAPI) { + let fiber = workInProgress.child; + while (fiber !== null) { + let nextFiber; + // Visit this fiber. + switch (fiber.tag) { + case ContextConsumer: + // Check if the context matches. + const observedBits: number = fiber.stateNode | 0; + if (fiber.type === context && (observedBits & changedBits) !== 0) { + // Update the expiration time of all the ancestors, including + // the alternates. + let node = fiber; + while (node !== null) { + const alternate = node.alternate; + if ( + node.expirationTime === NoWork || + node.expirationTime > renderExpirationTime + ) { + node.expirationTime = renderExpirationTime; + if ( + alternate !== null && + (alternate.expirationTime === NoWork || + alternate.expirationTime > renderExpirationTime) + ) { + alternate.expirationTime = renderExpirationTime; + } + } else if ( + alternate !== null && + (alternate.expirationTime === NoWork || + alternate.expirationTime > renderExpirationTime) + ) { + alternate.expirationTime = renderExpirationTime; + } else { + // Neither alternate was updated, which means the rest of the + // ancestor path already has sufficient priority. + break; + } + node = node.return; + } + // Don't scan deeper than a matching consumer. When we render the + // consumer, we'll continue scanning from that point. This way the + // scanning work is time-sliced. + nextFiber = null; + } else { + // Traverse down. + nextFiber = fiber.child; + } + break; + case ContextProvider: + // Don't scan deeper if this is a matching provider + nextFiber = fiber.type === workInProgress.type ? null : fiber.child; + break; + default: + // Traverse down. + nextFiber = fiber.child; + break; + } + if (nextFiber !== null) { + // Set the return pointer of the child to the work-in-progress fiber. + nextFiber.return = fiber; + } else { + // No child. Traverse to next sibling. + nextFiber = fiber; + while (nextFiber !== null) { + if (nextFiber === workInProgress) { + // We're back to the root of this subtree. Exit. + nextFiber = null; + break; + } + let sibling = nextFiber.sibling; + if (sibling !== null) { + nextFiber = sibling; + break; + } + // No more siblings. Traverse up. + nextFiber = nextFiber.return; + } + } + fiber = nextFiber; + } + } + } + + function updateContextProvider( + current, + workInProgress, + renderExpirationTime, + ) { + if (enableNewContextAPI) { + const providerType: ReactProviderType = workInProgress.type; + const context: ReactContext = providerType.context; + + const newProps = workInProgress.pendingProps; + const oldProps = workInProgress.memoizedProps; + + if (hasLegacyContextChanged()) { + // Normally we can bail out on props equality but if context has changed + // we don't do the bailout and we have to reuse existing props instead. + } else if (oldProps === newProps) { + workInProgress.stateNode = 0; + pushProvider(workInProgress); + return bailoutOnAlreadyFinishedWork(current, workInProgress); + } + workInProgress.memoizedProps = newProps; + + const newValue = newProps.value; + + let changedBits: number; + if (oldProps === null) { + // Initial render + changedBits = MAX_SIGNED_31_BIT_INT; + } else { + const oldValue = oldProps.value; + // 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)) || + (oldValue !== oldValue && newValue !== newValue) // eslint-disable-line no-self-compare + ) { + // No change. + changedBits = 0; + } else { + changedBits = + context.calculateChangedBits !== null + ? 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, + ); + } + changedBits |= 0; + + if (changedBits !== 0) { + propagateContextChange( + workInProgress, + context, + changedBits, + renderExpirationTime, + ); + } + } + } + + workInProgress.stateNode = changedBits; + pushProvider(workInProgress); + + if (oldProps !== null && oldProps.children === newProps.children) { + return bailoutOnAlreadyFinishedWork(current, workInProgress); + } + const newChildren = newProps.children; + reconcileChildren(current, workInProgress, newChildren); + return workInProgress.child; + } else { + return null; + } + } + + function updateContextConsumer( + current, + workInProgress, + renderExpirationTime, + ) { + if (enableNewContextAPI) { + const context: ReactContext = workInProgress.type; + const newProps = workInProgress.pendingProps; + + const newValue = context.currentValue; + const changedBits = context.changedBits; + + if (changedBits !== 0) { + // Context change propagation stops at matching consumers, for time- + // slicing. Continue the propagation here. + propagateContextChange( + workInProgress, + context, + changedBits, + renderExpirationTime, + ); + } + + // Store the observedBits on the fiber's stateNode for quick access. + let observedBits = newProps.observedBits; + if (observedBits === undefined || observedBits === null) { + // Subscribe to all changes by default + observedBits = MAX_SIGNED_31_BIT_INT; + } + workInProgress.stateNode = observedBits; + + const newChildren = newProps.render(newValue); + reconcileChildren(current, workInProgress, newChildren); + return workInProgress.child; + } else { + return null; + } + } + /* function reuseChildrenEffects(returnFiber : Fiber, firstChild : Fiber) { let child = firstChild; @@ -710,7 +923,7 @@ export default function( pushHostRootContext(workInProgress); break; case ClassComponent: - pushContextProvider(workInProgress); + pushLegacyContextProvider(workInProgress); break; case HostPortal: pushHostContainer( @@ -718,6 +931,9 @@ export default function( workInProgress.stateNode.containerInfo, ); break; + case ContextProvider: + pushProvider(workInProgress); + break; } // TODO: What if this is currently in progress? // How can that happen? How is this not being cloned? @@ -796,6 +1012,18 @@ export default function( return updateFragment(current, workInProgress); case Mode: return updateMode(current, workInProgress); + case ContextProvider: + return updateContextProvider( + current, + workInProgress, + renderExpirationTime, + ); + case ContextConsumer: + return updateContextConsumer( + current, + workInProgress, + renderExpirationTime, + ); default: invariant( false, @@ -813,7 +1041,7 @@ export default function( // Push context providers here to avoid a push/pop context mismatch. switch (workInProgress.tag) { case ClassComponent: - pushContextProvider(workInProgress); + pushLegacyContextProvider(workInProgress); break; case HostRoot: pushHostRootContext(workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index d63f70ebbb..3cf970b844 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -30,6 +30,8 @@ import { CallComponent, CallHandlerPhase, ReturnComponent, + ContextProvider, + ContextConsumer, Fragment, Mode, } from 'shared/ReactTypeOfWork'; @@ -38,9 +40,10 @@ import invariant from 'fbjs/lib/invariant'; import {reconcileChildFibers} from './ReactChildFiber'; import { - popContextProvider, - popTopLevelContextObject, + popContextProvider as popLegacyContextProvider, + popTopLevelContextObject as popTopLevelLegacyContextObject, } from './ReactFiberContext'; +import {popProvider} from './ReactFiberNewContext'; export default function( config: HostConfig, @@ -401,12 +404,12 @@ export default function( return null; case ClassComponent: { // We are leaving this subtree, so pop context if any. - popContextProvider(workInProgress); + popLegacyContextProvider(workInProgress); return null; } case HostRoot: { popHostContainer(workInProgress); - popTopLevelContextObject(workInProgress); + popTopLevelLegacyContextObject(workInProgress); const fiberRoot = (workInProgress.stateNode: FiberRoot); if (fiberRoot.pendingContext) { fiberRoot.context = fiberRoot.pendingContext; @@ -583,6 +586,12 @@ export default function( popHostContainer(workInProgress); updateHostContainer(workInProgress); return null; + case ContextProvider: + // Pop provider fiber + popProvider(workInProgress); + return null; + case ContextConsumer: + return null; // Error cases case IndeterminateComponent: invariant( diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js index 182740f895..dca42c4e3d 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js @@ -7,12 +7,14 @@ * @flow */ +import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt'; + // TODO: Use an opaque type once ESLint et al support the syntax export type ExpirationTime = number; export const NoWork = 0; export const Sync = 1; -export const Never = 2147483647; // Max int32: Math.pow(2, 31) - 1 +export const Never = MAX_SIGNED_31_BIT_INT; const UNIT_SIZE = 10; const MAGIC_NUMBER_OFFSET = 2; diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js new file mode 100644 index 0000000000..ba59ae5998 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -0,0 +1,70 @@ +/** + * 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 {Fiber} from './ReactFiber'; +import type {ReactContext} from 'shared/ReactTypes'; + +import warning from 'fbjs/lib/warning'; + +let stack: Array = []; +let index = -1; + +let rendererSigil; +if (__DEV__) { + // Use this to detect multiple renderers using the same context + rendererSigil = {}; +} + +export function pushProvider(providerFiber: Fiber): void { + index += 1; + stack[index] = providerFiber; + const context: ReactContext = providerFiber.type.context; + context.currentValue = providerFiber.pendingProps.value; + context.changedBits = providerFiber.stateNode; + + if (__DEV__) { + warning( + context._currentRenderer === null || + context._currentRenderer === rendererSigil, + 'Detected multiple renderers concurrently rendering the ' + + 'same context provider. This is currently unsupported.', + ); + context._currentRenderer = rendererSigil; + } +} + +export function popProvider(providerFiber: Fiber): void { + if (__DEV__) { + warning(index > -1 && providerFiber === stack[index], 'Unexpected pop.'); + } + stack[index] = null; + index -= 1; + const context: ReactContext = providerFiber.type.context; + if (index < 0) { + context.currentValue = context.defaultValue; + context.changedBits = 0; + } else { + const previousProviderFiber = stack[index]; + context.currentValue = previousProviderFiber.pendingProps.value; + context.changedBits = previousProviderFiber.stateNode; + } +} + +export function resetProviderStack(): void { + for (let i = index; i > -1; i--) { + const providerFiber = stack[i]; + const context: ReactContext = providerFiber.type.context; + context.currentValue = context.defaultValue; + context.changedBits = 0; + stack[i] = null; + if (__DEV__) { + context._currentRenderer = null; + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index a2d0a5d354..e223f2aa26 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -33,6 +33,7 @@ import { HostComponent, HostPortal, ClassComponent, + ContextProvider, } from 'shared/ReactTypeOfWork'; import {enableUserTimingAPI} from 'shared/ReactFeatureFlags'; import getComponentName from 'shared/getComponentName'; @@ -64,6 +65,7 @@ import { stopCommitLifeCyclesTimer, } from './ReactDebugFiberPerf'; import {popContextProvider} from './ReactFiberContext'; +import {popProvider} from './ReactFiberNewContext'; import {reset} from './ReactFiberStack'; import {logCapturedError} from './ReactFiberErrorLogger'; import {createWorkInProgress} from './ReactFiber'; @@ -78,7 +80,8 @@ import { } from './ReactFiberExpirationTime'; import {AsyncUpdates} from './ReactTypeOfInternalContext'; import {getUpdateExpirationTime} from './ReactFiberUpdateQueue'; -import {resetContext} from './ReactFiberContext'; +import {resetContext as resetLegacyContext} from './ReactFiberContext'; +import {resetProviderStack} from './ReactFiberNewContext'; const { invokeGuardedCallback, @@ -238,7 +241,8 @@ export default function( // Reset the stack reset(); // Reset the cursors - resetContext(); + resetLegacyContext(); + resetProviderStack(); resetHostContainer(); } @@ -1134,6 +1138,9 @@ export default function( case HostPortal: popHostContainer(node); break; + case ContextProvider: + popProvider(node); + break; } if (node === to || node.alternate === to) { stopFailedWorkTimer(node); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js new file mode 100644 index 0000000000..cf366cf05a --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -0,0 +1,784 @@ +/** + * 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 = require('shared/ReactFeatureFlags'); +ReactFeatureFlags.enableNewContextAPI = true; + +let React = require('react'); +let ReactNoop; +let gen; + +describe('ReactNewContext', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableNewContextAPI = true; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + gen = require('random-seed'); + }); + + // function div(...children) { + // children = children.map(c => (typeof c === 'string' ? {text: c} : c)); + // return {type: 'div', children, prop: undefined}; + // } + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + it('simple mount and update', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + return Context.consume(value => { + return ; + }); + } + + const Indirection = React.Fragment; + + function App(props) { + return ( + + + + + + + + ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Result: 2')]); + + // Update + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Result: 3')]); + }); + + it('propagates through shouldComponentUpdate false', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + ReactNoop.yield('Provider'); + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + ReactNoop.yield('Consumer'); + return Context.consume(value => { + ReactNoop.yield('Consumer render prop'); + return ; + }); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + ReactNoop.yield('Indirection'); + return this.props.children; + } + } + + function App(props) { + ReactNoop.yield('App'); + return ( + + + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + 'Indirection', + 'Indirection', + 'Consumer', + 'Consumer render prop', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: 2')]); + + // Update + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + 'Consumer render prop', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: 3')]); + }); + + it('consumers bail out if context value is the same', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + ReactNoop.yield('Provider'); + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + ReactNoop.yield('Consumer'); + return Context.consume(value => { + ReactNoop.yield('Consumer render prop'); + return ; + }); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + ReactNoop.yield('Indirection'); + return this.props.children; + } + } + + function App(props) { + ReactNoop.yield('App'); + return ( + + + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + 'Indirection', + 'Indirection', + 'Consumer', + 'Consumer render prop', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: 2')]); + + // Update with the same context value + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + // Don't call render prop again + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: 2')]); + }); + + it('nested providers', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + return Context.consume(contextValue => + // Multiply previous context value by 2, unless prop overrides + Context.provide(props.value || contextValue * 2, props.children), + ); + } + + function Consumer(props) { + return Context.consume(value => { + return ; + }); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( + + + + + + + + + + + + + + ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Result: 8')]); + + // Update + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Result: 12')]); + }); + + it('multiple consumers in different branches', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + return Context.consume(contextValue => + // Multiply previous context value by 2, unless prop overrides + Context.provide(props.value || contextValue * 2, props.children), + ); + } + + function Consumer(props) { + return Context.consume(value => { + return ; + }); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( + + + + + + + + + + + + + ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([ + span('Result: 4'), + span('Result: 2'), + ]); + + // Update + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([ + span('Result: 6'), + span('Result: 3'), + ]); + + // Another update + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([ + span('Result: 8'), + span('Result: 4'), + ]); + }); + + it('compares context values with Object.is semantics', () => { + const Context = React.unstable_createContext(1); + + function Provider(props) { + ReactNoop.yield('Provider'); + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + ReactNoop.yield('Consumer'); + return Context.consume(value => { + ReactNoop.yield('Consumer render prop'); + return ; + }); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + ReactNoop.yield('Indirection'); + return this.props.children; + } + } + + function App(props) { + ReactNoop.yield('App'); + return ( + + + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + 'Indirection', + 'Indirection', + 'Consumer', + 'Consumer render prop', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: NaN')]); + + // Update + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'App', + 'Provider', + // Consumer should not re-render again + // 'Consumer render prop', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Result: NaN')]); + }); + + it('context unwinds when interrupted', () => { + const Context = React.unstable_createContext('Default'); + + function Provider(props) { + return Context.provide(props.value, props.children); + } + + function Consumer(props) { + return Context.consume(value => { + return ; + }); + } + + function BadRender() { + throw new Error('Bad render'); + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + if (this.state.error) { + return null; + } + return this.props.children; + } + } + + function App(props) { + return ( + + + + + + + + + + + ); + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([ + // The second provider should use the default value. This proves the + span('Result: Does not unwind'), + ]); + }); + + it('can skip consumers with bitmask', () => { + const Context = React.unstable_createContext({foo: 0, bar: 0}, (a, b) => { + let result = 0; + if (a.foo !== b.foo) { + result |= 0b01; + } + if (a.bar !== b.bar) { + result |= 0b10; + } + return result; + }); + + function Provider(props) { + return Context.provide({foo: props.foo, bar: props.bar}, props.children); + } + + function Foo() { + return Context.consume(value => { + ReactNoop.yield('Foo'); + return ; + }, 0b01); + } + + function Bar() { + return Context.consume(value => { + ReactNoop.yield('Bar'); + return ; + }, 0b10); + } + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + return this.props.children; + } + } + + function App(props) { + return ( + + + + + + + + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Foo', 'Bar']); + expect(ReactNoop.getChildren()).toEqual([span('Foo: 1'), span('Bar: 1')]); + + // Update only foo + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Foo']); + expect(ReactNoop.getChildren()).toEqual([span('Foo: 2'), span('Bar: 1')]); + + // Update only bar + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Bar']); + expect(ReactNoop.getChildren()).toEqual([span('Foo: 2'), span('Bar: 2')]); + + // Update both + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Foo', 'Bar']); + expect(ReactNoop.getChildren()).toEqual([span('Foo: 3'), span('Bar: 3')]); + }); + + it('warns if calculateChangedBits returns larger than a 31-bit integer', () => { + spyOnDev(console, 'error'); + + const Context = React.unstable_createContext( + 0, + (a, b) => Math.pow(2, 32) - 1, // Return 32 bit int + ); + + function Provider(props) { + return Context.provide(props.value, props.children); + } + + ReactNoop.render(); + ReactNoop.flush(); + + // Update + ReactNoop.render(); + ReactNoop.flush(); + + if (__DEV__) { + expect(console.error.calls.count()).toBe(1); + expect(console.error.calls.argsFor(0)[0]).toContain( + 'calculateChangedBits: Expected the return value to be a 31-bit ' + + 'integer. Instead received: 4294967295', + ); + } + }); + + it('warns if multiple renderers concurrently render the same context', () => { + spyOnDev(console, 'error'); + const Context = React.unstable_createContext(0); + + function Foo(props) { + ReactNoop.yield('Foo'); + return null; + } + function Provider(props) { + return Context.provide(props.value, props.children); + } + + function App(props) { + return ( + + + + + ); + } + + ReactNoop.render(); + // Render past the Provider, but don't commit yet + ReactNoop.flushThrough(['Foo']); + + // Get a new copy of ReactNoop + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableNewContextAPI = true; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + + // Render the provider again using a different renderer + ReactNoop.render(); + ReactNoop.flush(); + + if (__DEV__) { + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Detected multiple renderers concurrently rendering the same ' + + 'context provider. This is currently unsupported', + ); + } + }); + + describe('fuzz test', () => { + const Fragment = React.Fragment; + const contextKeys = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; + + const FLUSH_ALL = 'FLUSH_ALL'; + function flushAll() { + return { + type: FLUSH_ALL, + toString() { + return `flushAll()`; + }, + }; + } + + const FLUSH = 'FLUSH'; + function flush(unitsOfWork) { + return { + type: FLUSH, + unitsOfWork, + toString() { + return `flush(${unitsOfWork})`; + }, + }; + } + + const UPDATE = 'UPDATE'; + function update(key, value) { + return { + type: UPDATE, + key, + value, + toString() { + return `update('${key}', ${value})`; + }, + }; + } + + function randomInteger(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min)) + min; + } + + function randomAction() { + switch (randomInteger(0, 3)) { + case 0: + return flushAll(); + case 1: + return flush(randomInteger(0, 500)); + case 2: + const key = contextKeys[randomInteger(0, contextKeys.length)]; + const value = randomInteger(1, 10); + return update(key, value); + default: + throw new Error('Switch statement should be exhaustive'); + } + } + + function randomActions(n) { + let actions = []; + for (let i = 0; i < n; i++) { + actions.push(randomAction()); + } + return actions; + } + + function ContextSimulator(maxDepth) { + const contexts = new Map( + contextKeys.map(key => { + const Context = React.unstable_createContext(0); + Context.displayName = 'Context' + key; + return [key, Context]; + }), + ); + + class ConsumerTree extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + if (this.props.depth >= this.props.maxDepth) { + return null; + } + const consumers = [0, 1, 2].map(i => { + const randomKey = + contextKeys[ + this.props.rand.intBetween(0, contextKeys.length - 1) + ]; + const Context = contexts.get(randomKey); + return Context.consume( + value => ( + + + + + ), + null, + i, + ); + }); + return consumers; + } + } + + function Root(props) { + return contextKeys.reduceRight((children, key) => { + const Context = contexts.get(key); + const value = props.values[key]; + return Context.provide(value, children); + }, ); + } + + const initialValues = contextKeys.reduce( + (result, key, i) => ({...result, [key]: i + 1}), + {}, + ); + + function assertConsistentTree(expectedValues = {}) { + const children = ReactNoop.getChildren(); + children.forEach(child => { + const text = child.prop; + const key = text[0]; + const value = parseInt(text[2], 10); + const expectedValue = expectedValues[key]; + if (expectedValue === undefined) { + // If an expected value was not explicitly passed to this function, + // use the first occurrence. + expectedValues[key] = value; + } else if (value !== expectedValue) { + throw new Error( + `Inconsistent value! Expected: ${key}:${expectedValue}. Actual: ${ + text + }`, + ); + } + }); + } + + function simulate(seed, actions) { + const rand = gen.create(seed); + let finalExpectedValues = initialValues; + function updateRoot() { + ReactNoop.render( + , + ); + } + updateRoot(); + + actions.forEach(action => { + switch (action.type) { + case FLUSH_ALL: + ReactNoop.flush(); + break; + case FLUSH: + ReactNoop.flushUnitsOfWork(action.unitsOfWork); + break; + case UPDATE: + finalExpectedValues = { + ...finalExpectedValues, + [action.key]: action.value, + }; + updateRoot(); + break; + default: + throw new Error('Switch statement should be exhaustive'); + } + assertConsistentTree(); + }); + + ReactNoop.flush(); + assertConsistentTree(finalExpectedValues); + } + + return {simulate}; + } + + it('hard-coded tests', () => { + const {simulate} = ContextSimulator(5); + simulate('randomSeed', [flush(3), update('A', 4)]); + }); + + it('generated tests', () => { + const {simulate} = ContextSimulator(5); + + const LIMIT = 100; + for (let i = 0; i < LIMIT; i++) { + const seed = Math.random() + .toString(36) + .substr(2, 5); + const actions = randomActions(5); + try { + simulate(seed, actions); + } catch (error) { + console.error(` +Context fuzz tester error! Copy and paste the following line into the test suite: + simulate('${seed}', ${actions.join(', ')}); +`); + throw error; + } + } + }); + }); +}); diff --git a/packages/react-reconciler/src/maxSigned31BitInt.js b/packages/react-reconciler/src/maxSigned31BitInt.js new file mode 100644 index 0000000000..2b6e167d0d --- /dev/null +++ b/packages/react-reconciler/src/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; diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 81247eba6f..110dd0a9c8 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -18,12 +18,14 @@ import { cloneElement, isValidElement, } from './ReactElement'; +import {createContext} from 'shared/ReactContext'; import { createElementWithValidation, createFactoryWithValidation, cloneElementWithValidation, } from './ReactElementValidator'; import ReactDebugCurrentFrame from './ReactDebugCurrentFrame'; +import {enableNewContextAPI} from 'shared/ReactFeatureFlags'; const React = { Children: { @@ -55,6 +57,10 @@ const React = { }, }; +if (enableNewContextAPI) { + React.unstable_createContext = createContext; +} + if (__DEV__) { Object.assign(React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, { // These should not be included in production. diff --git a/packages/shared/ReactContext.js b/packages/shared/ReactContext.js new file mode 100644 index 0000000000..2cc5bd6c43 --- /dev/null +++ b/packages/shared/ReactContext.js @@ -0,0 +1,91 @@ +/** + * 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 { + REACT_PROVIDER_TYPE, + REACT_CONTEXT_TYPE, + REACT_ELEMENT_TYPE, +} from 'shared/ReactSymbols'; + +import type { + ReactContext, + ReactConsumer, + ReactProvider, + ReactNodeList, +} from 'shared/ReactTypes'; + +import warning from 'fbjs/lib/warning'; + +export function createContext( + defaultValue: T, + calculateChangedBits: ?(a: T, b: T) => number, +): ReactContext { + let providerType; + + if (calculateChangedBits === undefined) { + calculateChangedBits = null; + } else { + if (__DEV__) { + warning( + calculateChangedBits === null || + typeof calculateChangedBits === 'function', + 'createContext: Expected the optional second argument to be a ' + + 'function. Instead received: %s', + calculateChangedBits, + ); + } + } + + const context: ReactContext = { + $$typeof: REACT_CONTEXT_TYPE, + provide(value: T, children: ReactNodeList, key?: string): ReactProvider { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: providerType, + key: key === null || key === undefined ? null : '' + key, + ref: null, + props: { + value, + children, + }, + }; + }, + consume( + render: (value: T) => ReactNodeList, + observedBits?: number, + key?: string, + ): ReactConsumer { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: context, + key: key === null || key === undefined ? null : '' + key, + ref: null, + props: { + observedBits, + render, + }, + }; + }, + calculateChangedBits, + defaultValue, + currentValue: defaultValue, + changedBits: 0, + }; + + providerType = { + $$typeof: REACT_PROVIDER_TYPE, + context, + }; + + if (__DEV__) { + context._currentRenderer = null; + } + + return context; +} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index a84b176d38..83b07a03f8 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -20,6 +20,8 @@ export const enableMutatingReconciler = true; export const enableNoopReconciler = false; // Experimental persistent mode (Fabric): export const enablePersistentReconciler = false; +// Support for new context API +export const enableNewContextAPI = false; // Helps identify side effects in begin-phase lifecycle hooks and setState reducers: export const debugRenderPhaseSideEffects = false; diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index de205bd2d2..037d4a38d0 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -27,6 +27,12 @@ export const REACT_FRAGMENT_TYPE = hasSymbol export const REACT_STRICT_MODE_TYPE = hasSymbol ? Symbol.for('react.strict_mode') : 0xeacc; +export const REACT_PROVIDER_TYPE = hasSymbol + ? Symbol.for('react.provider') + : 0xeacd; +export const REACT_CONTEXT_TYPE = hasSymbol + ? Symbol.for('react.context') + : 0xeace; const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactTypeOfWork.js b/packages/shared/ReactTypeOfWork.js index 899323c6c4..3d1bfc3f37 100644 --- a/packages/shared/ReactTypeOfWork.js +++ b/packages/shared/ReactTypeOfWork.js @@ -7,7 +7,21 @@ * @flow */ -export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; +export type TypeOfWork = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13; export const IndeterminateComponent = 0; // Before we know whether it is functional or class export const FunctionalComponent = 1; @@ -21,3 +35,5 @@ export const CallHandlerPhase = 8; export const ReturnComponent = 9; export const Fragment = 10; export const Mode = 11; +export const ContextConsumer = 12; +export const ContextProvider = 13; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index c68864323d..0e29678f35 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -14,7 +14,9 @@ export type ReactNode = | ReactReturn | ReactPortal | ReactText - | ReactFragment; + | ReactFragment + | ReactProvider + | ReactConsumer; export type ReactFragment = ReactEmpty | Iterable; @@ -47,6 +49,50 @@ export type ReactReturn = { }, }; +export type ReactProvider = { + $$typeof: Symbol | number, + type: ReactProviderType, + key: null | string, + ref: null, + props: { + value: T, + children?: ReactNodeList, + }, +}; + +export type ReactProviderType = { + $$typeof: Symbol | number, + context: ReactContext, +}; + +export type ReactConsumer = { + $$typeof: Symbol | number, + type: ReactContext, + key: null | string, + ref: null, + props: { + render: (value: T) => ReactNodeList, + bits?: number, + }, +}; + +export type ReactContext = { + $$typeof: Symbol | number, + provide(value: T, children: ReactNodeList, key?: string): ReactProvider, + consume( + render: (value: T) => ReactNodeList, + observedBits?: number, + key?: string, + ): ReactConsumer, + calculateChangedBits: ((a: T, b: T) => number) | null, + defaultValue: T, + currentValue: T, + changedBits: number, + + // DEV only + _currentRenderer?: Object | null, +}; + export type ReactPortal = { $$typeof: Symbol | number, key: null | string, diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric.js b/packages/shared/forks/ReactFeatureFlags.native-fabric.js index 21222cbf40..689df2c13c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric.js @@ -17,6 +17,7 @@ export const enableAsyncSubtreeAPI = true; export const enableCreateRoot = false; export const enableUserTimingAPI = __DEV__; export const warnAboutDeprecatedLifecycles = false; +export const enableNewContextAPI = false; // React Fabric uses persistent reconciler. export const enableMutatingReconciler = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native.js b/packages/shared/forks/ReactFeatureFlags.native.js index df48a0a700..38c6675c95 100644 --- a/packages/shared/forks/ReactFeatureFlags.native.js +++ b/packages/shared/forks/ReactFeatureFlags.native.js @@ -25,6 +25,7 @@ export const enableUserTimingAPI = __DEV__; export const enableMutatingReconciler = true; export const enableNoopReconciler = false; export const enablePersistentReconciler = false; +export const enableNewContextAPI = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 6c59c6708e..23eb86bfab 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -19,6 +19,7 @@ export const { // The rest of the flags are static for better dead code elimination. export const enableAsyncSubtreeAPI = true; export const enableCreateRoot = true; +export const enableNewContextAPI = true; // The www bundles only use the mutating reconciler. export const enableMutatingReconciler = true; diff --git a/yarn.lock b/yarn.lock index 72cc17ce0f..04dc3dee1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4373,6 +4373,12 @@ qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +random-seed@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/random-seed/-/random-seed-0.3.0.tgz#d945f2e1f38f49e8d58913431b8bf6bb937556cd" + dependencies: + json-stringify-safe "^5.0.1" + randomatic@^1.1.3: version "1.1.6" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.6.tgz#110dcabff397e9dcff7c0789ccc0a49adf1ec5bb"