diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationNewContext-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationNewContext-test.js index ee2afb4fae37..5f941da8f9a9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationNewContext-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationNewContext-test.js @@ -348,5 +348,96 @@ describe('ReactDOMServerIntegration', () => { await render(, 1); }, ); + + it('does not pollute parallel node streams', () => { + const LoggedInUser = React.createContext(); + + const AppWithUser = user => ( + +
+ {whoAmI => whoAmI} +
+
+ {whoAmI => whoAmI} +
+
+ ); + + const streamAmy = ReactDOMServer.renderToNodeStream( + AppWithUser('Amy'), + ).setEncoding('utf8'); + const streamBob = ReactDOMServer.renderToNodeStream( + AppWithUser('Bob'), + ).setEncoding('utf8'); + + // Testing by filling the buffer using internal _read() with a small + // number of bytes to avoid a test case which needs to align to a + // highWaterMark boundary of 2^14 chars. + streamAmy._read(20); + streamBob._read(20); + streamAmy._read(20); + streamBob._read(20); + + expect(streamAmy.read()).toBe('
Amy
'); + expect(streamBob.read()).toBe('
Bob
'); + }); + + it('does not pollute parallel node streams when many are used', () => { + const CurrentIndex = React.createContext(); + + const NthRender = index => ( + +
+ {idx => idx} +
+
+ {idx => idx} +
+
+ ); + + let streams = []; + + // Test with more than 32 streams to test that growing the thread count + // works properly. + let streamCount = 34; + + for (let i = 0; i < streamCount; i++) { + streams[i] = ReactDOMServer.renderToNodeStream( + NthRender(i % 2 === 0 ? 'Expected to be recreated' : i), + ).setEncoding('utf8'); + } + + // Testing by filling the buffer using internal _read() with a small + // number of bytes to avoid a test case which needs to align to a + // highWaterMark boundary of 2^14 chars. + for (let i = 0; i < streamCount; i++) { + streams[i]._read(20); + } + + // Early destroy every other stream + for (let i = 0; i < streamCount; i += 2) { + streams[i].destroy(); + } + + // Recreate those same streams. + for (let i = 0; i < streamCount; i += 2) { + streams[i] = ReactDOMServer.renderToNodeStream( + NthRender(i), + ).setEncoding('utf8'); + } + + // Read a bit from all streams again. + for (let i = 0; i < streamCount; i++) { + streams[i]._read(20); + } + + // Assert that all stream rendered the expected output. + for (let i = 0; i < streamCount; i++) { + expect(streams[i].read()).toBe( + '
' + i + '
', + ); + } + }); }); }); diff --git a/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js b/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js index 162b8e9d76ac..7478d86cc3f1 100644 --- a/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js +++ b/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js @@ -18,11 +18,15 @@ class ReactMarkupReadableStream extends Readable { this.partialRenderer = new ReactPartialRenderer(element, makeStaticMarkup); } + _destroy() { + this.partialRenderer.destroy(); + } + _read(size) { try { this.push(this.partialRenderer.read(size)); } catch (err) { - this.emit('error', err); + this.destroy(err); } } } diff --git a/packages/react-dom/src/server/ReactDOMStringRenderer.js b/packages/react-dom/src/server/ReactDOMStringRenderer.js index 50b20cb03935..1afc65acd6d5 100644 --- a/packages/react-dom/src/server/ReactDOMStringRenderer.js +++ b/packages/react-dom/src/server/ReactDOMStringRenderer.js @@ -14,8 +14,12 @@ import ReactPartialRenderer from './ReactPartialRenderer'; */ export function renderToString(element) { const renderer = new ReactPartialRenderer(element, false); - const markup = renderer.read(Infinity); - return markup; + try { + const markup = renderer.read(Infinity); + return markup; + } finally { + renderer.destroy(); + } } /** @@ -25,6 +29,10 @@ export function renderToString(element) { */ export function renderToStaticMarkup(element) { const renderer = new ReactPartialRenderer(element, true); - const markup = renderer.read(Infinity); - return markup; + try { + const markup = renderer.read(Infinity); + return markup; + } finally { + renderer.destroy(); + } } diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index c5f124e7c4a9..dfc1aba8c88c 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -7,6 +7,7 @@ * @flow */ +import type {ThreadID} from './ReactThreadIDAllocator'; import type {ReactElement} from 'shared/ReactElementType'; import type {ReactProvider, ReactContext} from 'shared/ReactTypes'; @@ -16,7 +17,6 @@ import getComponentName from 'shared/getComponentName'; import lowPriorityWarning from 'shared/lowPriorityWarning'; import warning from 'shared/warning'; import warningWithoutStack from 'shared/warningWithoutStack'; -import checkPropTypes from 'prop-types/checkPropTypes'; import describeComponentFrame from 'shared/describeComponentFrame'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -39,6 +39,12 @@ import { REACT_MEMO_TYPE, } from 'shared/ReactSymbols'; +import { + emptyObject, + processContext, + validateContextBounds, +} from './ReactPartialRendererContext'; +import {allocThreadID, freeThreadID} from './ReactThreadIDAllocator'; import { createMarkupForCustomAttribute, createMarkupForProperty, @@ -50,6 +56,8 @@ import { finishHooks, Dispatcher, DispatcherWithoutHooks, + currentThreadID, + setCurrentThreadID, } from './ReactPartialRendererHooks'; import { Namespaces, @@ -176,7 +184,6 @@ const didWarnAboutBadClass = {}; const didWarnAboutDeprecatedWillMount = {}; const didWarnAboutUndefinedDerivedState = {}; const didWarnAboutUninitializedState = {}; -const didWarnAboutInvalidateContextType = {}; const valuePropNames = ['value', 'defaultValue']; const newlineEatingTags = { listing: true, @@ -324,65 +331,6 @@ function flattenOptionChildren(children: mixed): ?string { return content; } -const emptyObject = {}; -if (__DEV__) { - Object.freeze(emptyObject); -} - -function maskContext(type, context) { - const contextTypes = type.contextTypes; - if (!contextTypes) { - return emptyObject; - } - const maskedContext = {}; - for (const contextName in contextTypes) { - maskedContext[contextName] = context[contextName]; - } - return maskedContext; -} - -function checkContextTypes(typeSpecs, values, location: string) { - if (__DEV__) { - checkPropTypes( - typeSpecs, - values, - location, - 'Component', - getCurrentServerStackImpl, - ); - } -} - -function processContext(type, context) { - const contextType = type.contextType; - if (typeof contextType === 'object' && contextType !== null) { - if (__DEV__) { - if (contextType.$$typeof !== REACT_CONTEXT_TYPE) { - let name = getComponentName(type) || 'Component'; - if (!didWarnAboutInvalidateContextType[name]) { - didWarnAboutInvalidateContextType[type] = true; - warningWithoutStack( - false, - '%s defines an invalid contextType. ' + - 'contextType should point to the Context object returned by React.createContext(). ' + - 'Did you accidentally pass the Context.Provider instead?', - name, - ); - } - } - } - return contextType._currentValue; - } else { - const maskedContext = maskContext(type, context); - if (__DEV__) { - if (type.contextTypes) { - checkContextTypes(type.contextTypes, maskedContext, 'context'); - } - } - return maskedContext; - } -} - const hasOwnProperty = Object.prototype.hasOwnProperty; const STYLE = 'style'; const RESERVED_PROPS = { @@ -453,6 +401,7 @@ function validateRenderResult(child, type) { function resolve( child: mixed, context: Object, + threadID: ThreadID, ): {| child: mixed, context: Object, @@ -472,7 +421,7 @@ function resolve( // Extra closure so queue and replace can be captured properly function processChild(element, Component) { - let publicContext = processContext(Component, context); + let publicContext = processContext(Component, context, threadID); let queue = []; let replace = false; @@ -718,6 +667,7 @@ type FrameDev = Frame & { }; class ReactDOMServerRenderer { + threadID: ThreadID; stack: Array; exhausted: boolean; // TODO: type this more strictly: @@ -747,6 +697,7 @@ class ReactDOMServerRenderer { if (__DEV__) { ((topFrame: any): FrameDev).debugElementStack = []; } + this.threadID = allocThreadID(); this.stack = [topFrame]; this.exhausted = false; this.currentSelectValue = null; @@ -763,6 +714,13 @@ class ReactDOMServerRenderer { } } + destroy() { + if (!this.exhausted) { + this.exhausted = true; + freeThreadID(this.threadID); + } + } + /** * Note: We use just two stacks regardless of how many context providers you have. * Providers are always popped in the reverse order to how they were pushed @@ -776,7 +734,9 @@ class ReactDOMServerRenderer { pushProvider(provider: ReactProvider): void { const index = ++this.contextIndex; const context: ReactContext = provider.type._context; - const previousValue = context._currentValue; + const threadID = this.threadID; + validateContextBounds(context, threadID); + const previousValue = context[threadID]; // Remember which value to restore this context to on our way up. this.contextStack[index] = context; @@ -787,7 +747,7 @@ class ReactDOMServerRenderer { } // Mutate the current value. - context._currentValue = provider.props.value; + context[threadID] = provider.props.value; } popProvider(provider: ReactProvider): void { @@ -813,7 +773,9 @@ class ReactDOMServerRenderer { this.contextIndex--; // Restore to the previous value we stored as we were walking down. - context._currentValue = previousValue; + // We've already verified that this context has been expanded to accommodate + // this thread id, so we don't need to do it again. + context[this.threadID] = previousValue; } read(bytes: number): string | null { @@ -821,6 +783,8 @@ class ReactDOMServerRenderer { return null; } + const prevThreadID = currentThreadID; + setCurrentThreadID(this.threadID); const prevDispatcher = ReactCurrentOwner.currentDispatcher; if (enableHooks) { ReactCurrentOwner.currentDispatcher = Dispatcher; @@ -835,6 +799,7 @@ class ReactDOMServerRenderer { while (out[0].length < bytes) { if (this.stack.length === 0) { this.exhausted = true; + freeThreadID(this.threadID); break; } const frame: Frame = this.stack[this.stack.length - 1]; @@ -906,6 +871,7 @@ class ReactDOMServerRenderer { return out[0]; } finally { ReactCurrentOwner.currentDispatcher = prevDispatcher; + setCurrentThreadID(prevThreadID); } } @@ -929,7 +895,7 @@ class ReactDOMServerRenderer { return escapeTextForBrowser(text); } else { let nextChild; - ({child: nextChild, context} = resolve(child, context)); + ({child: nextChild, context} = resolve(child, context, this.threadID)); if (nextChild === null || nextChild === false) { return ''; } else if (!React.isValidElement(nextChild)) { @@ -1136,7 +1102,9 @@ class ReactDOMServerRenderer { } } const nextProps: any = (nextChild: any).props; - const nextValue = reactContext._currentValue; + const threadID = this.threadID; + validateContextBounds(reactContext, threadID); + const nextValue = reactContext[threadID]; const nextChildren = toArray(nextProps.children(nextValue)); const frame: Frame = { diff --git a/packages/react-dom/src/server/ReactPartialRendererContext.js b/packages/react-dom/src/server/ReactPartialRendererContext.js new file mode 100644 index 000000000000..6a0e1fad16f3 --- /dev/null +++ b/packages/react-dom/src/server/ReactPartialRendererContext.js @@ -0,0 +1,104 @@ +/** + * 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 {ThreadID} from './ReactThreadIDAllocator'; +import type {ReactContext} from 'shared/ReactTypes'; + +import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import getComponentName from 'shared/getComponentName'; +import warningWithoutStack from 'shared/warningWithoutStack'; +import checkPropTypes from 'prop-types/checkPropTypes'; + +let ReactDebugCurrentFrame; +if (__DEV__) { + ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; +} + +const didWarnAboutInvalidateContextType = {}; + +export const emptyObject = {}; +if (__DEV__) { + Object.freeze(emptyObject); +} + +function maskContext(type, context) { + const contextTypes = type.contextTypes; + if (!contextTypes) { + return emptyObject; + } + const maskedContext = {}; + for (const contextName in contextTypes) { + maskedContext[contextName] = context[contextName]; + } + return maskedContext; +} + +function checkContextTypes(typeSpecs, values, location: string) { + if (__DEV__) { + checkPropTypes( + typeSpecs, + values, + location, + 'Component', + ReactDebugCurrentFrame.getCurrentStack, + ); + } +} + +export function validateContextBounds( + context: ReactContext, + threadID: ThreadID, +) { + // If we don't have enough slots in this context to store this threadID, + // fill it in without leaving any holes to ensure that the VM optimizes + // this as non-holey index properties. + for (let i = context._threadCount; i <= threadID; i++) { + // We assume that this is the same as the defaultValue which might not be + // true if we're rendering inside a secondary renderer but they are + // secondary because these use cases are very rare. + context[i] = context._currentValue2; + context._threadCount = i + 1; + } +} + +export function processContext( + type: Function, + context: Object, + threadID: ThreadID, +) { + const contextType = type.contextType; + if (typeof contextType === 'object' && contextType !== null) { + if (__DEV__) { + if (contextType.$$typeof !== REACT_CONTEXT_TYPE) { + let name = getComponentName(type) || 'Component'; + if (!didWarnAboutInvalidateContextType[name]) { + didWarnAboutInvalidateContextType[name] = true; + warningWithoutStack( + false, + '%s defines an invalid contextType. ' + + 'contextType should point to the Context object returned by React.createContext(). ' + + 'Did you accidentally pass the Context.Provider instead?', + name, + ); + } + } + } + validateContextBounds(contextType, threadID); + return contextType[threadID]; + } else { + const maskedContext = maskContext(type, context); + if (__DEV__) { + if (type.contextTypes) { + checkContextTypes(type.contextTypes, maskedContext, 'context'); + } + } + return maskedContext; + } +} diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index d3ddf0d6b615..2860ee9bfbeb 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -6,9 +6,13 @@ * * @flow */ + +import type {ThreadID} from './ReactThreadIDAllocator'; import type {ReactContext} from 'shared/ReactTypes'; import areHookInputsEqual from 'shared/areHookInputsEqual'; +import {validateContextBounds} from './ReactPartialRendererContext'; + import invariant from 'shared/invariant'; import warning from 'shared/warning'; @@ -139,7 +143,9 @@ function readContext( context: ReactContext, observedBits: void | number | boolean, ): T { - return context._currentValue; + let threadID = currentThreadID; + validateContextBounds(context, threadID); + return context[threadID]; } function useContext( @@ -147,7 +153,9 @@ function useContext( observedBits: void | number | boolean, ): T { resolveCurrentlyRenderingComponent(); - return context._currentValue; + let threadID = currentThreadID; + validateContextBounds(context, threadID); + return context[threadID]; } function basicStateReducer(state: S, action: BasicStateAction): S { @@ -334,6 +342,12 @@ function dispatchAction( function noop(): void {} +export let currentThreadID: ThreadID = 0; + +export function setCurrentThreadID(threadID: ThreadID) { + currentThreadID = threadID; +} + export const Dispatcher = { readContext, useContext, diff --git a/packages/react-dom/src/server/ReactThreadIDAllocator.js b/packages/react-dom/src/server/ReactThreadIDAllocator.js new file mode 100644 index 000000000000..1bba2dd50fca --- /dev/null +++ b/packages/react-dom/src/server/ReactThreadIDAllocator.js @@ -0,0 +1,58 @@ +/** + * 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 + */ + +// Allocates a new index for each request. Tries to stay as compact as possible so that these +// indices can be used to reference a tightly packaged array. As opposed to being used in a Map. +// The first allocated index is 1. + +import invariant from 'shared/invariant'; + +export type ThreadID = number; + +let nextAvailableThreadIDs = new Uint16Array(16); +for (let i = 0; i < 15; i++) { + nextAvailableThreadIDs[i] = i + 1; +} +nextAvailableThreadIDs[15] = 0; + +function growThreadCountAndReturnNextAvailable() { + let oldArray = nextAvailableThreadIDs; + let oldSize = oldArray.length; + let newSize = oldSize * 2; + invariant( + newSize <= 0x10000, + 'Maximum number of concurrent React renderers exceeded. ' + + 'This can happen if you are not properly destroying the Readable provided by React. ' + + 'Ensure that you call .destroy() on it if you no longer want to read from it.' + + ', and did not read to the end. If you use .pipe() this should be automatic.', + ); + let newArray = new Uint16Array(newSize); + newArray.set(oldArray); + nextAvailableThreadIDs = newArray; + nextAvailableThreadIDs[0] = oldSize + 1; + for (let i = oldSize; i < newSize - 1; i++) { + nextAvailableThreadIDs[i] = i + 1; + } + nextAvailableThreadIDs[newSize - 1] = 0; + return oldSize; +} + +export function allocThreadID(): ThreadID { + let nextID = nextAvailableThreadIDs[0]; + if (nextID === 0) { + return growThreadCountAndReturnNextAvailable(); + } + nextAvailableThreadIDs[0] = nextAvailableThreadIDs[nextID]; + return nextID; +} + +export function freeThreadID(id: ThreadID) { + nextAvailableThreadIDs[id] = nextAvailableThreadIDs[0]; + nextAvailableThreadIDs[0] = id; +} diff --git a/packages/react/src/ReactContext.js b/packages/react/src/ReactContext.js index 71d1b3860544..643a13019e07 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -42,6 +42,9 @@ export function createContext( // Secondary renderers store their context values on separate fields. _currentValue: defaultValue, _currentValue2: defaultValue, + // Used to track how many concurrent renderers this context currently + // supports within in a single renderer. Such as parallel server rendering. + _threadCount: 0, // These are circular Provider: (null: any), Consumer: (null: any), @@ -98,6 +101,14 @@ export function createContext( context._currentValue2 = _currentValue2; }, }, + _threadCount: { + get() { + return context._threadCount; + }, + set(_threadCount) { + context._threadCount = _threadCount; + }, + }, Consumer: { get() { if (!hasWarnedAboutUsingNestedContextConsumers) { diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 080ade12b807..f09207928cea 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -59,6 +59,7 @@ export type ReactContext = { _currentValue: T, _currentValue2: T, + _threadCount: number, // DEV only _currentRenderer?: Object | null, diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 82c236f7c222..a1b4a7fdfac4 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -12,6 +12,7 @@ module.exports = { Proxy: true, Symbol: true, WeakMap: true, + Uint16Array: true, // Vendor specific MSApp: true, __REACT_DEVTOOLS_GLOBAL_HOOK__: true, diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index 527fd0a473c9..2e2949444f5d 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -12,6 +12,7 @@ module.exports = { Symbol: true, Proxy: true, WeakMap: true, + Uint16Array: true, // Vendor specific MSApp: true, __REACT_DEVTOOLS_GLOBAL_HOOK__: true, diff --git a/scripts/rollup/validate/eslintrc.umd.js b/scripts/rollup/validate/eslintrc.umd.js index 7ee1e112f2b0..a3d5fadf2c55 100644 --- a/scripts/rollup/validate/eslintrc.umd.js +++ b/scripts/rollup/validate/eslintrc.umd.js @@ -11,6 +11,7 @@ module.exports = { Symbol: true, Proxy: true, WeakMap: true, + Uint16Array: true, // Vendor specific MSApp: true, __REACT_DEVTOOLS_GLOBAL_HOOK__: true,