From fe95af9e344b9ae8c31e568d372c267b9f2feb7b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 28 May 2024 14:06:30 -0400 Subject: [PATCH] Throw if React and React DOM versions don't match (#29236) Throw an error during module initialization if the version of the "react-dom" package does not match the version of "react". We used to be more relaxed about this, because the "react" package changed so infrequently. However, we now have many more features that rely on an internal protocol between the two packages, including Hooks, Float, and the compiler runtime. So it's important that both packages are versioned in lockstep. Before this change, a version mismatch would often result in a cryptic internal error with no indication of the root cause. Instead, we will now compare the versions during module initialization and immediately throw an error to catch mistakes as early as possible and provide a clear error message. --- .../react-dom/src/client/ReactDOMClient.js | 3 + .../react-dom/src/client/ReactDOMClientFB.js | 3 + .../src/server/ReactDOMFizzServerBrowser.js | 3 + .../src/server/ReactDOMFizzServerBun.js | 3 + .../src/server/ReactDOMFizzServerEdge.js | 3 + .../src/server/ReactDOMFizzServerNode.js | 3 + .../src/server/ReactDOMFizzStaticBrowser.js | 3 + .../src/server/ReactDOMFizzStaticEdge.js | 3 + .../src/server/ReactDOMFizzStaticNode.js | 3 + .../ensureCorrectIsomorphicReactVersion.js | 24 +++ .../src/ReactNativeRenderer.js | 14 ++ .../__tests__/ReactMismatchedVersions-test.js | 143 ++++++++++++++++++ .../useSyncExternalStoreShared-test.js | 7 + scripts/error-codes/codes.json | 3 +- 14 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/react-dom/src/shared/ensureCorrectIsomorphicReactVersion.js create mode 100644 packages/react/src/__tests__/ReactMismatchedVersions-test.js diff --git a/packages/react-dom/src/client/ReactDOMClient.js b/packages/react-dom/src/client/ReactDOMClient.js index 036fd846a47ba..3837f322a53ca 100644 --- a/packages/react-dom/src/client/ReactDOMClient.js +++ b/packages/react-dom/src/client/ReactDOMClient.js @@ -19,6 +19,9 @@ import ReactVersion from 'shared/ReactVersion'; import {getClosestInstanceFromNode} from 'react-dom-bindings/src/client/ReactDOMComponentTree'; import Internals from 'shared/ReactDOMSharedInternals'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + if (__DEV__) { if ( typeof Map !== 'function' || diff --git a/packages/react-dom/src/client/ReactDOMClientFB.js b/packages/react-dom/src/client/ReactDOMClientFB.js index 1f918e3cf68c0..a3985860728e6 100644 --- a/packages/react-dom/src/client/ReactDOMClientFB.js +++ b/packages/react-dom/src/client/ReactDOMClientFB.js @@ -25,6 +25,9 @@ import {createPortal as createPortalImpl} from 'react-reconciler/src/ReactPortal import {canUseDOM} from 'shared/ExecutionEnvironment'; import ReactVersion from 'shared/ReactVersion'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + import { getClosestInstanceFromNode, getInstanceFromNode, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 14c4a597922ee..8879a511d3fa0 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -37,6 +37,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index 4cceb66e7cdf7..750c3133c4518 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -31,6 +31,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js index 14c4a597922ee..8879a511d3fa0 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -37,6 +37,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 049849a664559..f0c9d75fc7107 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -41,6 +41,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index cbc5cd4044361..f5d6a45a18c8c 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -36,6 +36,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index e1fc514b7da5b..1a2eb1e599afe 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -36,6 +36,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index 3c3c4116a5e54..fc25aa75c190a 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -37,6 +37,9 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import {ensureCorrectIsomorphicReactVersion} from '../shared/ensureCorrectIsomorphicReactVersion'; +ensureCorrectIsomorphicReactVersion(); + type Options = { identifierPrefix?: string, namespaceURI?: string, diff --git a/packages/react-dom/src/shared/ensureCorrectIsomorphicReactVersion.js b/packages/react-dom/src/shared/ensureCorrectIsomorphicReactVersion.js new file mode 100644 index 0000000000000..cbfece89832f3 --- /dev/null +++ b/packages/react-dom/src/shared/ensureCorrectIsomorphicReactVersion.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 reactDOMPackageVersion from 'shared/ReactVersion'; +import * as IsomorphicReactPackage from 'react'; + +export function ensureCorrectIsomorphicReactVersion() { + const isomorphicReactPackageVersion = IsomorphicReactPackage.version; + if (isomorphicReactPackageVersion !== reactDOMPackageVersion) { + throw new Error( + 'Incompatible React versions: The "react" and "react-dom" packages must ' + + 'have the exact same version. Instead got:\n' + + ` - react: ${isomorphicReactPackageVersion}\n` + + ` - react-dom: ${reactDOMPackageVersion}\n` + + 'Learn more: https://react.dev/warnings/version-mismatch', + ); + } +} diff --git a/packages/react-native-renderer/src/ReactNativeRenderer.js b/packages/react-native-renderer/src/ReactNativeRenderer.js index 528c8abe91800..5c2957aed16e5 100644 --- a/packages/react-native-renderer/src/ReactNativeRenderer.js +++ b/packages/react-native-renderer/src/ReactNativeRenderer.js @@ -56,6 +56,20 @@ import {disableLegacyMode} from 'shared/ReactFeatureFlags'; // Module provided by RN: import {ReactFiberErrorDialog} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; +import reactNativePackageVersion from 'shared/ReactVersion'; +import * as IsomorphicReactPackage from 'react'; + +const isomorphicReactPackageVersion = IsomorphicReactPackage.version; +if (isomorphicReactPackageVersion !== reactNativePackageVersion) { + throw new Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must ' + + 'have the exact same version. Instead got:\n' + + ` - react: ${isomorphicReactPackageVersion}\n` + + ` - react-native-renderer: ${reactNativePackageVersion}\n` + + 'Learn more: https://react.dev/warnings/version-mismatch', + ); +} + if (typeof ReactFiberErrorDialog.showErrorDialog !== 'function') { throw new Error( 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', diff --git a/packages/react/src/__tests__/ReactMismatchedVersions-test.js b/packages/react/src/__tests__/ReactMismatchedVersions-test.js new file mode 100644 index 0000000000000..cee86e5087d6b --- /dev/null +++ b/packages/react/src/__tests__/ReactMismatchedVersions-test.js @@ -0,0 +1,143 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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'; + +describe('ReactMismatchedVersions-test', () => { + // Polyfills for test environment + global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; + global.TextEncoder = require('util').TextEncoder; + + let React; + let actualReactVersion; + + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => { + const actualReact = jest.requireActual('react'); + return { + ...actualReact, + version: '18.0.0-whoa-this-aint-the-right-react', + __actualVersion: actualReact.version, + }; + }); + React = require('react'); + actualReactVersion = React.__actualVersion; + }); + + test('importing "react-dom/client" throws if version does not match React version', async () => { + expect(() => require('react-dom/client')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // When running in source mode, we lazily require the implementation to + // simulate the static config dependency injection we do at build time. So it + // only errors once you call something and trigger the require. Running the + // test in build mode is sufficient. + // @gate !source + test('importing "react-dom/server" throws if version does not match React version', async () => { + expect(() => require('react-dom/server')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // @gate !source + test('importing "react-dom/server.node" throws if version does not match React version', async () => { + expect(() => require('react-dom/server.node')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // @gate !source + test('importing "react-dom/server.browser" throws if version does not match React version', async () => { + expect(() => require('react-dom/server.browser')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // @gate !source + test('importing "react-dom/server.bun" throws if version does not match React version', async () => { + expect(() => require('react-dom/server.bun')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // @gate !source + test('importing "react-dom/server.edge" throws if version does not match React version', async () => { + expect(() => require('react-dom/server.edge')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + test('importing "react-dom/static" throws if version does not match React version', async () => { + expect(() => require('react-dom/static')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + test('importing "react-dom/static.node" throws if version does not match React version', async () => { + expect(() => require('react-dom/static.node')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + test('importing "react-dom/static.browser" throws if version does not match React version', async () => { + expect(() => require('react-dom/static.browser')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + test('importing "react-dom/static.edge" throws if version does not match React version', async () => { + expect(() => require('react-dom/static.edge')).toThrow( + 'Incompatible React versions: The "react" and "react-dom" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-dom: ${actualReactVersion}`, + ); + }); + + // @gate source + test('importing "react-native-renderer" throws if version does not match React version', async () => { + expect(() => require('react-native-renderer')).toThrow( + 'Incompatible React versions: The "react" and "react-native-renderer" packages ' + + 'must have the exact same version. Instead got:\n' + + ' - react: 18.0.0-whoa-this-aint-the-right-react\n' + + ` - react-native-renderer: ${actualReactVersion}`, + ); + }); +}); diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js index 8f8fb8e9a9a5a..1ad3de60dadbe 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -43,6 +43,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { : 'react-dom-17/umd/react-dom.production.min.js', ), ); + jest.mock('react-dom/client', () => + jest.requireActual( + __DEV__ + ? 'react-dom-17/umd/react-dom.development.js' + : 'react-dom-17/umd/react-dom.production.min.js', + ), + ); // Because React 17 prints extra logs we need to ignore them. originalError = console.error; console.error = jest.fn(); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index f0e73bd6fb4f3..c26ae4cce22b8 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -511,5 +511,6 @@ "523": "The render was aborted due to being postponed.", "524": "Values cannot be passed to next() of AsyncIterables passed to Client Components.", "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", - "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server." + "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", + "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch" }