diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 9346f20f0442..edbb2e9f2e97 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -727,4 +727,70 @@ describe('ReactDOMFizzServer', () => { , ); }); + + // @gate experimental + it('should resume the context from where it left off', async () => { + const ContextA = React.createContext('A0'); + const ContextB = React.createContext('B0'); + + function PrintA() { + return ( + {value => } + ); + } + + class PrintB extends React.Component { + static contextType = ContextB; + render() { + return ; + } + } + + function AsyncParent({text, children}) { + return ( + <> + + {children} + + ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( +
+ +
+ + }> + + + + + + +
+ +
, + writable, + ); + startWriting(); + }); + expect(getVisibleChildren(container)).toEqual( +
+ A0
Loading...
A0 +
, + ); + await act(async () => { + resolveText('Child:'); + }); + expect(getVisibleChildren(container)).toEqual( +
+ A0 +
+ Child:A0.1B0 +
+ A0 +
, + ); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 45cd0fecb5e9..521fe04439c1 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -48,6 +48,10 @@ import hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; +// Used to distinguish these contexts from ones used in other renderers. +// E.g. this can be used to distinguish legacy renderers from this modern one. +export const isPrimaryRenderer = true; + // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { placeholderPrefix: PrecomputedChunk, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 40ab6b84b26a..223734c0483d 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -23,6 +23,8 @@ import { import invariant from 'shared/invariant'; +export const isPrimaryRenderer = true; + // Every list of children or string is null terminated. const END_TAG = 0; // Tree node tags. diff --git a/packages/react-server/src/ReactFizzClassComponent.js b/packages/react-server/src/ReactFizzClassComponent.js index d56bc633b849..a7385d54aa78 100644 --- a/packages/react-server/src/ReactFizzClassComponent.js +++ b/packages/react-server/src/ReactFizzClassComponent.js @@ -8,6 +8,7 @@ */ import {emptyContextObject} from './ReactFizzContext'; +import {readContext} from './ReactFizzNewContext'; import {disableLegacyContext} from 'shared/ReactFeatureFlags'; import {get as getInstance, set as setInstance} from 'shared/ReactInstanceMap'; @@ -211,9 +212,7 @@ export function constructClassInstance( } if (typeof contextType === 'object' && contextType !== null) { - // TODO: Implement Context. - // context = readContext((contextType: any)); - throw new Error('Context is not yet implemented.'); + context = readContext((contextType: any)); } else if (!disableLegacyContext) { context = maskedLegacyContext; } @@ -617,9 +616,7 @@ export function mountClassInstance( const contextType = ctor.contextType; if (typeof contextType === 'object' && contextType !== null) { - // TODO: Implement Context. - // instance.context = readContext(contextType); - throw new Error('Context is not yet implemented.'); + instance.context = readContext(contextType); } else if (disableLegacyContext) { instance.context = emptyContextObject; } else { diff --git a/packages/react-server/src/ReactFizzNewContext.js b/packages/react-server/src/ReactFizzNewContext.js new file mode 100644 index 000000000000..43f364717d81 --- /dev/null +++ b/packages/react-server/src/ReactFizzNewContext.js @@ -0,0 +1,278 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; + +import {isPrimaryRenderer} from './ReactServerFormatConfig'; + +import invariant from 'shared/invariant'; + +let rendererSigil; +if (__DEV__) { + // Use this to detect multiple renderers using the same context + rendererSigil = {}; +} + +// Used to store the parent path of all context overrides in a shared linked list. +// Forming a reverse tree. +type ContextNode = { + parent: null | ContextNode, + depth: number, // Short hand to compute the depth of the tree at this node. + context: ReactContext, + parentValue: T, + value: T, +}; + +// The structure of a context snapshot is an implementation of this file. +// Currently, it's implemented as tracking the current active node. +export opaque type ContextSnapshot = null | ContextNode; + +export const rootContextSnapshot: ContextSnapshot = null; + +// We assume that this runtime owns the "current" field on all ReactContext instances. +// This global (actually thread local) state represents what state all those "current", +// fields are currently in. +let currentActiveSnapshot: ContextSnapshot = null; + +function popNode(prev: ContextNode): void { + if (isPrimaryRenderer) { + prev.context._currentValue = prev.parentValue; + } else { + prev.context._currentValue2 = prev.parentValue; + } +} + +function pushNode(next: ContextNode): void { + if (isPrimaryRenderer) { + next.context._currentValue = next.value; + } else { + next.context._currentValue2 = next.value; + } +} + +function popToNearestCommonAncestor( + prev: ContextNode, + next: ContextNode, +): void { + if (prev === next) { + // We've found a shared ancestor. We don't need to pop nor reapply this one or anything above. + } else { + popNode(prev); + const parentPrev = prev.parent; + const parentNext = next.parent; + if (parentPrev === null) { + invariant( + parentNext === null, + 'The stacks must reach the root at the same time. This is a bug in React.', + ); + } else { + invariant( + parentNext !== null, + 'The stacks must reach the root at the same time. This is a bug in React.', + ); + popToNearestCommonAncestor(parentPrev, parentNext); + // On the way back, we push the new ones that weren't common. + pushNode(next); + } + } +} + +function popAllPrevious(prev: ContextNode): void { + popNode(prev); + const parentPrev = prev.parent; + if (parentPrev !== null) { + popAllPrevious(parentPrev); + } +} + +function pushAllNext(next: ContextNode): void { + const parentNext = next.parent; + if (parentNext !== null) { + pushAllNext(parentNext); + } + pushNode(next); +} + +function popPreviousToCommonLevel( + prev: ContextNode, + next: ContextNode, +): void { + popNode(prev); + const parentPrev = prev.parent; + invariant( + parentPrev !== null, + 'The depth must equal at least at zero before reaching the root. This is a bug in React.', + ); + if (parentPrev.depth === next.depth) { + // We found the same level. Now we just need to find a shared ancestor. + popToNearestCommonAncestor(parentPrev, next); + } else { + // We must still be deeper. + popPreviousToCommonLevel(parentPrev, next); + } +} + +function popNextToCommonLevel( + prev: ContextNode, + next: ContextNode, +): void { + const parentNext = next.parent; + invariant( + parentNext !== null, + 'The depth must equal at least at zero before reaching the root. This is a bug in React.', + ); + if (prev.depth === parentNext.depth) { + // We found the same level. Now we just need to find a shared ancestor. + popToNearestCommonAncestor(prev, parentNext); + } else { + // We must still be deeper. + popNextToCommonLevel(prev, parentNext); + } + pushNode(next); +} + +// Perform context switching to the new snapshot. +// To make it cheap to read many contexts, while not suspending, we make the switch eagerly by +// updating all the context's current values. That way reads, always just read the current value. +// At the cost of updating contexts even if they're never read by this subtree. +export function switchContext(newSnapshot: ContextSnapshot): void { + // The basic algorithm we need to do is to pop back any contexts that are no longer on the stack. + // We also need to update any new contexts that are now on the stack with the deepest value. + // The easiest way to update new contexts is to just reapply them in reverse order from the + // perspective of the backpointers. To avoid allocating a lot when switching, we use the stack + // for that. Therefore this algorithm is recursive. + // 1) First we pop which ever snapshot tree was deepest. Popping old contexts as we go. + // 2) Then we find the nearest common ancestor from there. Popping old contexts as we go. + // 3) Then we reapply new contexts on the way back up the stack. + const prev = currentActiveSnapshot; + const next = newSnapshot; + if (prev !== next) { + if (prev === null) { + // $FlowFixMe: This has to be non-null since it's not equal to prev. + pushAllNext(next); + } else if (next === null) { + popAllPrevious(prev); + } else if (prev.depth === next.depth) { + popToNearestCommonAncestor(prev, next); + } else if (prev.depth > next.depth) { + popPreviousToCommonLevel(prev, next); + } else { + popNextToCommonLevel(prev, next); + } + currentActiveSnapshot = next; + } +} + +export function pushProvider( + context: ReactContext, + nextValue: T, +): ContextSnapshot { + let prevValue; + if (isPrimaryRenderer) { + prevValue = context._currentValue; + context._currentValue = nextValue; + if (__DEV__) { + if ( + context._currentRenderer !== undefined && + context._currentRenderer !== null && + context._currentRenderer !== rendererSigil + ) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same context provider. This is currently unsupported.', + ); + } + context._currentRenderer = rendererSigil; + } + } else { + prevValue = context._currentValue2; + context._currentValue2 = nextValue; + if (__DEV__) { + if ( + context._currentRenderer2 !== undefined && + context._currentRenderer2 !== null && + context._currentRenderer2 !== rendererSigil + ) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same context provider. This is currently unsupported.', + ); + } + context._currentRenderer2 = rendererSigil; + } + } + const prevNode = currentActiveSnapshot; + const newNode: ContextNode = { + parent: prevNode, + depth: prevNode === null ? 0 : prevNode.depth + 1, + context: context, + parentValue: prevValue, + value: nextValue, + }; + currentActiveSnapshot = newNode; + return newNode; +} + +export function popProvider(context: ReactContext): ContextSnapshot { + const prevSnapshot = currentActiveSnapshot; + invariant( + prevSnapshot !== null, + 'Tried to pop a Context at the root of the app. This is a bug in React.', + ); + if (__DEV__) { + if (prevSnapshot.context !== context) { + console.error( + 'The parent context is not the expected context. This is probably a bug in React.', + ); + } + } + if (isPrimaryRenderer) { + prevSnapshot.context._currentValue = prevSnapshot.parentValue; + if (__DEV__) { + if ( + context._currentRenderer !== undefined && + context._currentRenderer !== null && + context._currentRenderer !== rendererSigil + ) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same context provider. This is currently unsupported.', + ); + } + context._currentRenderer = rendererSigil; + } + } else { + prevSnapshot.context._currentValue2 = prevSnapshot.parentValue; + if (__DEV__) { + if ( + context._currentRenderer2 !== undefined && + context._currentRenderer2 !== null && + context._currentRenderer2 !== rendererSigil + ) { + console.error( + 'Detected multiple renderers concurrently rendering the ' + + 'same context provider. This is currently unsupported.', + ); + } + context._currentRenderer2 = rendererSigil; + } + } + return (currentActiveSnapshot = prevSnapshot.parent); +} + +export function getActiveContext(): ContextSnapshot { + return currentActiveSnapshot; +} + +export function readContext(context: ReactContext): T { + const value = isPrimaryRenderer + ? context._currentValue + : context._currentValue2; + return value; +} diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a3b1babdf546..c1d51c3b2422 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -13,12 +13,18 @@ import type { Chunk, PrecomputedChunk, } from './ReactServerStreamConfig'; -import type {ReactNodeList} from 'shared/ReactTypes'; +import type { + ReactNodeList, + ReactContext, + ReactProviderType, +} from 'shared/ReactTypes'; +import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { SuspenseBoundaryID, ResponseState, FormatContext, } from './ReactServerFormatConfig'; +import type {ContextSnapshot} from './ReactFizzNewContext'; import { scheduleWork, @@ -56,6 +62,15 @@ import { processChildContext, emptyContextObject, } from './ReactFizzContext'; +import { + readContext, + rootContextSnapshot, + switchContext, + getActiveContext, + pushProvider, + popProvider, +} from './ReactFizzNewContext'; + import { getIteratorFn, REACT_ELEMENT_TYPE, @@ -68,6 +83,10 @@ import { REACT_PROFILER_TYPE, REACT_SUSPENSE_LIST_TYPE, REACT_FRAGMENT_TYPE, + REACT_FORWARD_REF_TYPE, + REACT_MEMO_TYPE, + REACT_PROVIDER_TYPE, + REACT_CONTEXT_TYPE, } from 'shared/ReactSymbols'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -104,6 +123,7 @@ type Task = { blockedSegment: Segment, // the segment we'll write to abortSet: Set, // the abortable set that this task belongs to legacyContext: LegacyContext, // the current legacy context that this task is executing in + context: ContextSnapshot, // the current new context that this task is executing in assignID: null | SuspenseBoundaryID, // id to assign to the content }; @@ -220,6 +240,7 @@ export function createRequest( rootSegment, abortSet, emptyContextObject, + rootContextSnapshot, null, ); pingedTasks.push(rootTask); @@ -257,6 +278,7 @@ function createTask( blockedSegment: Segment, abortSet: Set, legacyContext: LegacyContext, + context: ContextSnapshot, assignID: null | SuspenseBoundaryID, ): Task { request.allPendingTasks++; @@ -272,6 +294,7 @@ function createTask( blockedSegment, abortSet, legacyContext, + context, assignID, }; abortSet.add(task); @@ -394,6 +417,7 @@ function renderSuspenseBoundary( boundarySegment, fallbackAbortSet, task.legacyContext, + task.context, newBoundary.id, // This is the ID we want to give this fallback so we can replace it later. ); // TODO: This should be queued at a separate lower priority queue so that we only work @@ -529,6 +553,7 @@ let didWarnAboutReassigningProps = false; const didWarnAboutDefaultPropsOnFunctionComponent = {}; let didWarnAboutGenerators = false; let didWarnAboutMaps = false; +let hasWarnedAboutUsingContextAsConsumer = false; // This would typically be a function component but we still support module pattern // components for some reason. @@ -694,49 +719,185 @@ function validateFunctionComponentInDev(Component: any): void { } } +function renderForwardRef( + request: Request, + task: Task, + type: any, + props: Object, +): void { + throw new Error('Not yet implemented element type.'); +} + +function renderMemo( + request: Request, + task: Task, + type: any, + props: Object, +): void { + throw new Error('Not yet implemented element type.'); +} + +function renderContextConsumer( + request: Request, + task: Task, + context: ReactContext, + props: Object, +): void { + // The logic below for Context differs depending on PROD or DEV mode. In + // DEV mode, we create a separate object for Context.Consumer that acts + // like a proxy to Context. This proxy object adds unnecessary code in PROD + // so we use the old behaviour (Context.Consumer references Context) to + // reduce size and overhead. The separate object references context via + // a property called "_context", which also gives us the ability to check + // in DEV mode if this property exists or not and warn if it does not. + if (__DEV__) { + if ((context: any)._context === undefined) { + // This may be because it's a Context (rather than a Consumer). + // Or it may be because it's older React where they're the same thing. + // We only want to warn if we're sure it's a new React. + if (context !== context.Consumer) { + if (!hasWarnedAboutUsingContextAsConsumer) { + hasWarnedAboutUsingContextAsConsumer = true; + console.error( + 'Rendering directly is not supported and will be removed in ' + + 'a future major release. Did you mean to render instead?', + ); + } + } + } else { + context = (context: any)._context; + } + } + const render = props.children; + + if (__DEV__) { + if (typeof render !== 'function') { + console.error( + 'A context consumer was rendered with multiple children, or a child ' + + "that isn't a function. A context consumer expects a single child " + + 'that is a function. If you did pass a function, make sure there ' + + 'is no trailing or leading whitespace around it.', + ); + } + } + + const newValue = readContext(context); + const newChildren = render(newValue); + + renderNodeDestructive(request, task, newChildren); +} + +function renderContextProvider( + request: Request, + task: Task, + type: ReactProviderType, + props: Object, +): void { + const context = type._context; + const value = props.value; + const children = props.children; + let prevSnapshot; + if (__DEV__) { + prevSnapshot = task.context; + } + task.context = pushProvider(context, value); + renderNodeDestructive(request, task, children); + task.context = popProvider(context); + if (__DEV__) { + if (prevSnapshot !== task.context) { + console.error( + 'Popping the context provider did not return back to the original snapshot. This is a bug in React.', + ); + } + } +} + +function renderLazyComponent( + request: Request, + task: Task, + type: LazyComponentType, + props: Object, +): void { + throw new Error('Not yet implemented element type.'); +} + function renderElement( request: Request, task: Task, type: any, props: Object, - node: ReactNodeList, ): void { if (typeof type === 'function') { if (shouldConstruct(type)) { renderClassComponent(request, task, type, props); + return; } else { renderIndeterminateComponent(request, task, type, props); + return; } - } else if (typeof type === 'string') { + } + if (typeof type === 'string') { renderHostElement(request, task, type, props); - } else { - switch (type) { - // TODO: LegacyHidden acts the same as a fragment. This only works - // because we currently assume that every instance of LegacyHidden is - // accompanied by a host component wrapper. In the hidden mode, the host - // component is given a `hidden` attribute, which ensures that the - // initial HTML is not visible. To support the use of LegacyHidden as a - // true fragment, without an extra DOM node, we would have to hide the - // initial HTML in some other way. - // TODO: Add REACT_OFFSCREEN_TYPE here too with the same capability. - case REACT_LEGACY_HIDDEN_TYPE: - case REACT_DEBUG_TRACING_MODE_TYPE: - case REACT_STRICT_MODE_TYPE: - case REACT_PROFILER_TYPE: - case REACT_SUSPENSE_LIST_TYPE: // TODO: SuspenseList should control the boundaries. - case REACT_FRAGMENT_TYPE: { - renderNodeDestructive(request, task, props.children); - break; + return; + } + + switch (type) { + // TODO: LegacyHidden acts the same as a fragment. This only works + // because we currently assume that every instance of LegacyHidden is + // accompanied by a host component wrapper. In the hidden mode, the host + // component is given a `hidden` attribute, which ensures that the + // initial HTML is not visible. To support the use of LegacyHidden as a + // true fragment, without an extra DOM node, we would have to hide the + // initial HTML in some other way. + // TODO: Add REACT_OFFSCREEN_TYPE here too with the same capability. + case REACT_LEGACY_HIDDEN_TYPE: + case REACT_DEBUG_TRACING_MODE_TYPE: + case REACT_STRICT_MODE_TYPE: + case REACT_PROFILER_TYPE: + case REACT_SUSPENSE_LIST_TYPE: // TODO: SuspenseList should control the boundaries. + case REACT_FRAGMENT_TYPE: { + renderNodeDestructive(request, task, props.children); + return; + } + case REACT_SUSPENSE_TYPE: { + renderSuspenseBoundary(request, task, props); + return; + } + } + + if (typeof type === 'object' && type !== null) { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: { + renderForwardRef(request, task, type, props); + return; } - case REACT_SUSPENSE_TYPE: { - renderSuspenseBoundary(request, task, props); - break; + case REACT_MEMO_TYPE: { + renderMemo(request, task, type, props); + return; } - default: { - throw new Error('Not yet implemented element type.'); + case REACT_PROVIDER_TYPE: { + renderContextProvider(request, task, type, props); + return; + } + case REACT_CONTEXT_TYPE: { + renderContextConsumer(request, task, type, props); + return; + } + case REACT_LAZY_TYPE: { + renderLazyComponent(request, task, type, props); + return; } } } + + 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, + '', + ); } function validateIterable(iterable, iteratorFn: Function): void { @@ -791,7 +952,7 @@ function renderNodeDestructive( const element: React$Element = (node: any); const type = element.type; const props = element.props; - renderElement(request, task, type, props, node); + renderElement(request, task, type, props); return; } case REACT_PORTAL_TYPE: @@ -918,6 +1079,7 @@ function spawnNewSuspendedTask( newSegment, task.abortSet, task.legacyContext, + task.context, task.assignID, ); // We've delegated the assignment. @@ -936,6 +1098,7 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void { // process. const previousFormatContext = task.blockedSegment.formatContext; const previousLegacyContext = task.legacyContext; + const previousContext = task.context; try { return renderNodeDestructive(request, task, node); } catch (x) { @@ -945,6 +1108,9 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void { // functions in case nothing throws so we don't use "finally" here. task.blockedSegment.formatContext = previousFormatContext; task.legacyContext = previousLegacyContext; + task.context = previousContext; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); } else { // We assume that we don't need the correct context. // Let's terminate the rest of the tree and don't render any siblings. @@ -1104,6 +1270,10 @@ function retryTask(request: Request, task: Task): void { // We completed this by other means before we had a chance to retry it. return; } + // We restore the context to what it was when we suspended. + // We don't restore it after we leave because it's likely that we'll end up + // needing a very similar context soon again. + switchContext(task.context); try { // We call the destructive form that mutates this task. That way if something // suspends again, we can reuse the same task instead of spawning a new one. @@ -1129,6 +1299,7 @@ function performWork(request: Request): void { if (request.status === CLOSED) { return; } + const prevContext = getActiveContext(); const prevDispatcher = ReactCurrentDispatcher.current; ReactCurrentDispatcher.current = Dispatcher; @@ -1148,6 +1319,16 @@ function performWork(request: Request): void { fatalError(request, error); } finally { ReactCurrentDispatcher.current = prevDispatcher; + if (prevDispatcher === Dispatcher) { + // This means that we were in a reentrant work loop. This could happen + // in a renderer that supports synchronous work like renderToString, + // when it's called from within another renderer. + // Normally we don't bother switching the contexts to their root/default + // values when leaving because we'll likely need the same or similar + // context again. However, when we're inside a synchronous loop like this + // we'll to restore the context to what it was before returning. + switchContext(prevContext); + } } } diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 58f2d504b46b..1ab6f9509082 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -29,6 +29,8 @@ export opaque type ResponseState = mixed; export opaque type FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; +export const isPrimaryRenderer = false; + export const getChildFormatContext = $$$hostConfig.getChildFormatContext; export const createSuspenseBoundaryID = $$$hostConfig.createSuspenseBoundaryID; export const pushEmpty = $$$hostConfig.pushEmpty; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 17c28c3bc701..4dbb95f55e80 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -388,5 +388,8 @@ "397": "Unknown insertion mode. This is a bug in React.", "398": "`dangerouslySetInnerHTML` does not work on