Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/release-notes/16.0.0.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 7 additions & 29 deletions node_package/src/ClientSideRenderer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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<void> {
Expand Down
4 changes: 0 additions & 4 deletions node_package/src/ReactDOMServer.cts

This file was deleted.

6 changes: 3 additions & 3 deletions node_package/src/ReactOnRails.client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,7 +10,6 @@ import * as Authenticity from './Authenticity.ts';
import type {
RegisteredComponent,
RenderResult,
RenderReturnType,
ReactComponentOrRenderFunction,
AuthenticityHeaders,
Store,
Expand Down Expand Up @@ -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);
},

Expand Down Expand Up @@ -128,7 +128,7 @@ globalThis.ReactOnRails = {
StoreRegistry.clearHydratedStores();
},

render(name: string, props: Record<string, string>, domNodeId: string, hydrate: boolean): RenderReturnType {
render(name: string, props: Record<string, string>, domNodeId: string, hydrate: boolean): Root {
const componentObj = ComponentRegistry.get(name);
const reactElement = createReactOutput({ componentObj, props, domNodeId });

Expand Down
2 changes: 1 addition & 1 deletion node_package/src/handleError.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
61 changes: 0 additions & 61 deletions node_package/src/reactApis.cts

This file was deleted.

10 changes: 10 additions & 0 deletions node_package/src/reactApis.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
}
};
13 changes: 9 additions & 4 deletions node_package/src/reactHydrateOrRender.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion node_package/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/streamServerRenderedReactComponent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
22 changes: 6 additions & 16 deletions node_package/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="react/experimental" />

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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string>, domNodeId: string, hydrate?: boolean): RenderReturnType;
render(name: string, props: Record<string, string>, domNodeId: string, hydrate?: boolean): Root;
/**
* Get the component that you registered
* @returns {name, component, renderFunction, isRenderer}
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/wrapServerComponentRenderer/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 4 additions & 8 deletions script/convert
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand Down
18 changes: 0 additions & 18 deletions spec/dummy/client/app-react16/startup/ManualRenderApp.jsx

This file was deleted.

Loading
Loading