diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f06f4e1f..71b36d8f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### Removed + +- Support for React 16 and 17. [PR 1710](https://github.com/shakacode/react_on_rails/pull/1710) by [alexeyr-ci](https://github.com/alexeyr-ci). + +#### Breaking Changes + +- React >=18 is now required. + ### [15.0.0] - 2025-08-28 See [Release Notes](docs/release-notes/15.0.0.md) for full details. diff --git a/docs/release-notes/16.0.0.md b/docs/release-notes/16.0.0.md new file mode 100644 index 0000000000..6640c00186 --- /dev/null +++ b/docs/release-notes/16.0.0.md @@ -0,0 +1,7 @@ +# React on Rails 16.0.0 Release Notes + +Also see the Changelog for 16.0.0 (TODO: insert link once released). + +## Breaking Changes + +- Support for React 16 and 17 is dropped. diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index b4978c5985..3414d44cf5 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -1,12 +1,12 @@ /* eslint-disable max-classes-per-file */ import type { ReactElement } from 'react'; -import type { RailsContext, RegisteredComponent, RenderFunction, Root } from './types/index.ts'; +import type { Root } from 'react-dom/client'; +import type { RailsContext, RegisteredComponent, RenderFunction } from './types/index.ts'; import { getRailsContext, resetRailsContext } from './context.ts'; import createReactOutput from './createReactOutput.ts'; import { isServerRenderHash } from './isServerRenderResult.ts'; -import { supportsHydrate, supportsRootApi, unmountComponentAtNode } from './reactApis.cts'; import reactHydrateOrRender from './reactHydrateOrRender.ts'; import { debugTurbolinks } from './turbolinksUtils.ts'; import * as StoreRegistry from './StoreRegistry.ts'; @@ -100,8 +100,8 @@ class ComponentRenderer { return; } - // Hydrate if available and was server rendered - const shouldHydrate = supportsHydrate && !!domNode.innerHTML; + // Hydrate if the node was server rendered + const shouldHydrate = !!domNode.innerHTML; const reactElementOrRouterResult = createReactOutput({ componentObj, @@ -117,15 +117,12 @@ class ComponentRenderer { You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} You should return a React.Component always for the client side entry point.`); } else { - const rootOrElement = reactHydrateOrRender( + this.root = reactHydrateOrRender( domNode, reactElementOrRouterResult as ReactElement, shouldHydrate, ); this.state = 'rendered'; - if (supportsRootApi) { - this.root = rootOrElement as Root; - } } } } catch (e: unknown) { @@ -143,27 +140,8 @@ You should return a React.Component always for the client side entry point.`); } this.state = 'unmounted'; - if (supportsRootApi) { - this.root?.unmount(); - this.root = undefined; - } else { - const domNode = document.getElementById(this.domNodeId); - if (!domNode) { - return; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-deprecated - unmountComponentAtNode(domNode); - } catch (e: unknown) { - const error = e instanceof Error ? e : new Error('Unknown error'); - console.info( - `Caught error calling unmountComponentAtNode: ${error.message} for domNode`, - domNode, - error, - ); - } - } + this.root?.unmount(); + this.root = undefined; } waitUntilRendered(): Promise { diff --git a/node_package/src/ReactDOMServer.cts b/node_package/src/ReactDOMServer.cts deleted file mode 100644 index ef455298ae..0000000000 --- a/node_package/src/ReactDOMServer.cts +++ /dev/null @@ -1,4 +0,0 @@ -// Depending on react-dom version, proper ESM import can be react-dom/server or react-dom/server.js -// but since we have a .cts file, it supports both. -// Remove this file and replace by imports directly from 'react-dom/server' when we drop React 16/17 support. -export { renderToPipeableStream, renderToString, type PipeableStream } from 'react-dom/server'; diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 752fba1d4c..1dbdcc2f12 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -1,4 +1,5 @@ import type { ReactElement } from 'react'; +import type { Root } from 'react-dom/client'; import * as ClientStartup from './clientStartup.ts'; import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer.ts'; import * as ComponentRegistry from './ComponentRegistry.ts'; @@ -9,7 +10,6 @@ import * as Authenticity from './Authenticity.ts'; import type { RegisteredComponent, RenderResult, - RenderReturnType, ReactComponentOrRenderFunction, AuthenticityHeaders, Store, @@ -64,7 +64,7 @@ globalThis.ReactOnRails = { return StoreRegistry.getOrWaitForStoreGenerator(name); }, - reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType { + reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): Root { return reactHydrateOrRender(domNode, reactElement, hydrate); }, @@ -128,7 +128,7 @@ globalThis.ReactOnRails = { StoreRegistry.clearHydratedStores(); }, - render(name: string, props: Record, domNodeId: string, hydrate: boolean): RenderReturnType { + render(name: string, props: Record, domNodeId: string, hydrate: boolean): Root { const componentObj = ComponentRegistry.get(name); const reactElement = createReactOutput({ componentObj, props, domNodeId }); diff --git a/node_package/src/handleError.ts b/node_package/src/handleError.ts index eb9c3b12c9..643ca7d861 100644 --- a/node_package/src/handleError.ts +++ b/node_package/src/handleError.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { renderToString } from './ReactDOMServer.cts'; +import { renderToString } from 'react-dom/server'; import type { ErrorOptions } from './types/index.ts'; function handleRenderFunctionIssue(options: ErrorOptions): string { diff --git a/node_package/src/reactApis.cts b/node_package/src/reactApis.cts deleted file mode 100644 index d4a258b120..0000000000 --- a/node_package/src/reactApis.cts +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable global-require,@typescript-eslint/no-require-imports */ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import type { ReactElement } from 'react'; -import type { RenderReturnType } from './types/index.ts' with { 'resolution-mode': 'import' }; - -const reactMajorVersion = Number(ReactDOM.version?.split('.')[0]) || 16; - -// TODO: once we require React 18, we can remove this and inline everything guarded by it. -export const supportsRootApi = reactMajorVersion >= 18; - -export const supportsHydrate = supportsRootApi || 'hydrate' in ReactDOM; - -// TODO: once React dependency is updated to >= 18, we can remove this and just -// import ReactDOM from 'react-dom/client'; -let reactDomClient: typeof import('react-dom/client'); -if (supportsRootApi) { - // This will never throw an exception, but it's the way to tell Webpack the dependency is optional - // https://github.com/webpack/webpack/issues/339#issuecomment-47739112 - // Unfortunately, it only converts the error to a warning. - try { - reactDomClient = require('react-dom/client') as typeof import('react-dom/client'); - } catch (_e) { - // We should never get here, but if we do, we'll just use the default ReactDOM - // and live with the warning. - reactDomClient = ReactDOM as unknown as typeof import('react-dom/client'); - } -} - -type HydrateOrRenderType = (domNode: Element, reactElement: ReactElement) => RenderReturnType; - -/* eslint-disable @typescript-eslint/no-deprecated,@typescript-eslint/no-non-null-assertion,react/no-deprecated -- - * while we need to support React 16 - */ -export const reactHydrate: HydrateOrRenderType = supportsRootApi - ? reactDomClient!.hydrateRoot - : (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode); - -export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType { - if (supportsRootApi) { - const root = reactDomClient!.createRoot(domNode); - root.render(reactElement); - return root; - } - - // eslint-disable-next-line react/no-render-return-value - return ReactDOM.render(reactElement, domNode); -} - -export const unmountComponentAtNode: typeof ReactDOM.unmountComponentAtNode = supportsRootApi - ? // not used if we use root API - () => false - : ReactDOM.unmountComponentAtNode; - -export const ensureReactUseAvailable = () => { - if (!('use' in React) || typeof React.use !== 'function') { - throw new Error( - 'React.use is not defined. Please ensure you are using React 19 to use server components.', - ); - } -}; diff --git a/node_package/src/reactApis.ts b/node_package/src/reactApis.ts new file mode 100644 index 0000000000..c4a0ec9efb --- /dev/null +++ b/node_package/src/reactApis.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; + +// eslint-disable-next-line import/prefer-default-export +export const ensureReactUseAvailable = () => { + if (!('use' in React) || typeof React.use !== 'function') { + throw new Error( + 'React.use is not defined. Please ensure you are using React 19 to use server components.', + ); + } +}; diff --git a/node_package/src/reactHydrateOrRender.ts b/node_package/src/reactHydrateOrRender.ts index 26b4717fa1..9ffbc2da39 100644 --- a/node_package/src/reactHydrateOrRender.ts +++ b/node_package/src/reactHydrateOrRender.ts @@ -1,11 +1,16 @@ import type { ReactElement } from 'react'; -import type { RenderReturnType } from './types/index.ts'; -import { reactHydrate, reactRender } from './reactApis.cts'; +import { createRoot, hydrateRoot, Root } from 'react-dom/client'; export default function reactHydrateOrRender( domNode: Element, reactElement: ReactElement, hydrate: boolean, -): RenderReturnType { - return hydrate ? reactHydrate(domNode, reactElement) : reactRender(domNode, reactElement); +): Root { + if (hydrate) { + return hydrateRoot(domNode, reactElement); + } + + const root = createRoot(domNode); + root.render(reactElement); + return root; } diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index a33bb5c144..290ffcb6ab 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,12 +1,12 @@ import * as React from 'react'; import type { ReactElement } from 'react'; +import { renderToString } from 'react-dom/server'; import * as ComponentRegistry from './ComponentRegistry.ts'; import createReactOutput from './createReactOutput.ts'; import { isPromise, isServerRenderHash } from './isServerRenderResult.ts'; import buildConsoleReplay from './buildConsoleReplay.ts'; import handleError from './handleError.ts'; -import { renderToString } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import type { CreateReactOutputResult, diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index cf6436021c..35e7da815d 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -1,12 +1,12 @@ import * as React from 'react'; import { PassThrough, Readable } from 'stream'; +import { renderToPipeableStream } from 'react-dom/server'; import * as ComponentRegistry from './ComponentRegistry.ts'; import createReactOutput from './createReactOutput.ts'; import { isPromise, isServerRenderHash } from './isServerRenderResult.ts'; import buildConsoleReplay from './buildConsoleReplay.ts'; import handleError from './handleError.ts'; -import { renderToPipeableStream } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import { assertRailsContextWithServerStreamingCapabilities, diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index a425a2d21b..21772c5ebc 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,6 +1,7 @@ /// -import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; +import type { ReactElement, ComponentType } from 'react'; +import type { Root } from 'react-dom/client'; import type { PipeableStream } from 'react-dom/server'; import type { Readable } from 'stream'; @@ -258,15 +259,6 @@ export interface RSCPayloadChunk extends RenderResult { html: string; } -// from react-dom 18 -export interface Root { - render(children: ReactNode): void; - unmount(): void; -} - -// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- inherited from React 16/17, can't avoid here -export type RenderReturnType = void | Element | Component | Root; - export interface ReactOnRailsOptions { /** Gives you debugging messages on Turbolinks events. */ traceTurbolinks?: boolean; @@ -316,9 +308,9 @@ export interface ReactOnRails { * @param domNode * @param reactElement * @param hydrate if true will perform hydration, if false will render - * @returns {Root|ReactComponent|ReactElement|null} + * @returns {Root} */ - reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; + reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): Root; /** * Allow directly calling the page loaded script in case the default events that trigger React * rendering are not sufficient, such as when loading JavaScript asynchronously with TurboLinks. @@ -401,11 +393,9 @@ export interface ReactOnRailsInternal extends ReactOnRails { * @param props Props to pass to your component * @param domNodeId HTML ID of the node the component will be rendered at * @param [hydrate=false] Pass truthy to update server rendered HTML. Default is falsy - * @returns {Root|ReactComponent|ReactElement} Under React 18+: the created React root - * (see "What is a root?" in https://github.com/reactwg/react-18/discussions/5). - * Under React 16/17: Reference to your component's backing instance or `null` for stateless components. + * @returns {Root} The created React root */ - render(name: string, props: Record, domNodeId: string, hydrate?: boolean): RenderReturnType; + render(name: string, props: Record, domNodeId: string, hydrate?: boolean): Root; /** * Get the component that you registered * @returns {name, component, renderFunction, isRenderer} diff --git a/node_package/src/wrapServerComponentRenderer/client.tsx b/node_package/src/wrapServerComponentRenderer/client.tsx index 491b7866b0..827c0a9c17 100644 --- a/node_package/src/wrapServerComponentRenderer/client.tsx +++ b/node_package/src/wrapServerComponentRenderer/client.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import * as ReactDOMClient from 'react-dom/client'; import { ReactComponentOrRenderFunction, RenderFunction } from '../types/index.ts'; import isRenderFunction from '../isRenderFunction.ts'; -import { ensureReactUseAvailable } from '../reactApis.cts'; +import { ensureReactUseAvailable } from '../reactApis.ts'; import { createRSCProvider } from '../RSCProvider.tsx'; import getReactServerComponent from '../getReactServerComponent.client.ts'; diff --git a/package.json b/package.json index d0511f224b..b691434961 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,8 @@ "typescript-eslint": "^8.35.0" }, "peerDependencies": { - "react": ">= 16", - "react-dom": ">= 16", + "react": ">= 18", + "react-dom": ">= 18", "react-on-rails-rsc": "19.0.2" }, "peerDependenciesMeta": { diff --git a/script/convert b/script/convert index bbc57bbaf1..3693488e0c 100755 --- a/script/convert +++ b/script/convert @@ -32,10 +32,10 @@ gsub_file_content("../package.json", %r{"@testing-library/[^"]*": "[^"]*",}, "") gsub_file_content("../package.json", /,(\s*})/, "\\1") # Switch to the oldest supported React version -gsub_file_content("../package.json", /"react": "[^"]*",/, '"react": "16.14.0",') -gsub_file_content("../package.json", /"react-dom": "[^"]*",/, '"react-dom": "16.14.0",') -gsub_file_content("../spec/dummy/package.json", /"react": "[^"]*",/, '"react": "16.14.0",') -gsub_file_content("../spec/dummy/package.json", /"react-dom": "[^"]*",/, '"react-dom": "16.14.0",') +gsub_file_content("../package.json", /"react": "[^"]*",/, '"react": "18.0.0",') +gsub_file_content("../package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') +gsub_file_content("../spec/dummy/package.json", /"react": "[^"]*",/, '"react": "18.0.0",') +gsub_file_content("../spec/dummy/package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') gsub_file_content( "../package.json", "jest node_package/tests", @@ -46,10 +46,6 @@ gsub_file_content("../tsconfig.json", "react-jsx", "react") gsub_file_content("../spec/dummy/babel.config.js", "runtime: 'automatic'", "runtime: 'classic'") # https://rescript-lang.org/docs/react/latest/migrate-react#configuration gsub_file_content("../spec/dummy/rescript.json", '"version": 4', '"version": 4, "mode": "classic"') -# Find all files under app-react16 and replace the React 19 versions -Dir.glob(File.expand_path("../spec/dummy/**/app-react16/**/*.*", __dir__)).each do |file| - move(file, file.gsub("-react16", "")) -end gsub_file_content("../spec/dummy/config/webpack/commonWebpackConfig.js", /generateWebpackConfig(\(\))?/, "webpackConfig") diff --git a/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx b/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx deleted file mode 100644 index 90b8d0db25..0000000000 --- a/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - -export default (props, _railsContext, domNodeId) => { - const reactElement = ( -
-

Manual Render Example

-

If you can see this, you can register renderer functions.

-
- ); - - const domNode = document.getElementById(domNodeId); - if (props.prerender) { - ReactDOM.hydrate(reactElement, domNode); - } else { - ReactDOM.render(reactElement, domNode); - } -}; diff --git a/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx b/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx deleted file mode 100644 index b7ab770286..0000000000 --- a/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx +++ /dev/null @@ -1,57 +0,0 @@ -// Top level component for client side. -// Compare this to the ./ServerApp.jsx file which is used for server side rendering. -// NOTE: these are basically the same, but they are shown here - -import React from 'react'; -import { combineReducers, applyMiddleware, createStore } from 'redux'; -import { Provider } from 'react-redux'; -import thunkMiddleware from 'redux-thunk'; -import ReactDOM from 'react-dom'; - -import reducers from '../../app/reducers/reducersIndex'; -import composeInitialState from '../../app/store/composeInitialState'; - -import HelloWorldContainer from '../../app/components/HelloWorldContainer'; - -/* - * Export a function that takes the props and returns a ReactComponent. - * This is used for the client rendering hook after the page html is rendered. - * React will see that the state is the same and not do anything. - * - */ -export default (props, railsContext, domNodeId) => { - const render = props.prerender ? ReactDOM.hydrate : ReactDOM.render; - // eslint-disable-next-line no-param-reassign - delete props.prerender; - - const combinedReducer = combineReducers(reducers); - const combinedProps = composeInitialState(props, railsContext); - - // This is where we'll put in the middleware for the async function. Placeholder. - // store will have helloWorldData as a top level property - const store = createStore(combinedReducer, combinedProps, applyMiddleware(thunkMiddleware)); - - // renderApp is a function required for hot reloading. see - // https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js - - // Provider uses this.props.children, so we're not typical React syntax. - // This allows redux to add additional props to the HelloWorldContainer. - const renderApp = (Komponent) => { - const element = ( - - - - ); - - render(element, document.getElementById(domNodeId)); - }; - - renderApp(HelloWorldContainer); - - if (module.hot) { - module.hot.accept(['../reducers/reducersIndex', '../components/HelloWorldContainer'], () => { - store.replaceReducer(combineReducers(reducers)); - renderApp(HelloWorldContainer); - }); - } -}; diff --git a/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx b/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx deleted file mode 100644 index 05706772ab..0000000000 --- a/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx +++ /dev/null @@ -1,45 +0,0 @@ -// Top level component for the client side. -// Compare this to the ./ReduxSharedStoreApp.server.jsx file which is used for server side rendering. - -import React from 'react'; -import { Provider } from 'react-redux'; -import ReactOnRails from 'react-on-rails/client'; -import ReactDOM from 'react-dom'; - -import HelloWorldContainer from '../../app/components/HelloWorldContainer'; - -/* - * Export a function that returns a ReactComponent, depending on a store named SharedReduxStore. - * This is used for the client rendering hook after the page html is rendered. - * React will see that the state is the same and not do anything. - */ -export default (props, _railsContext, domNodeId) => { - const render = props.prerender ? ReactDOM.hydrate : ReactDOM.render; - // eslint-disable-next-line no-param-reassign - delete props.prerender; - - // This is where we get the existing store. - const store = ReactOnRails.getStore('SharedReduxStore'); - - // renderApp is a function required for hot reloading. see - // https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js - - // Provider uses this.props.children, so we're not typical React syntax. - // This allows redux to add additional props to the HelloWorldContainer. - const renderApp = (Component) => { - const element = ( - - - - ); - render(element, document.getElementById(domNodeId)); - }; - - renderApp(HelloWorldContainer); - - if (module.hot) { - module.hot.accept(['../components/HelloWorldContainer'], () => { - renderApp(HelloWorldContainer); - }); - } -};