Skip to content

Switch to ESM #1726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from 14 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
6 changes: 2 additions & 4 deletions .github/workflows/lint-js-and-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,8 @@ jobs:
- name: Pack for attw and publint
run: yarn pack -f react-on-rails.tgz
- name: Lint package types
# --profile because we don't care about node10
# --ignore-rules CJS default export can't be resolved at the moment,
# revisit in 15.0.0
run: yarn run attw react-on-rails.tgz --profile node16 --ignore-rules cjs-only-exports-default
# our package is ESM-only
run: yarn run attw react-on-rails.tgz --profile esm-only
- name: Lint package publishing
run: yarn run publint --strict react-on-rails.tgz
# We only download and run Actionlint if there is any difference in GitHub Action workflows
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ jobs:
run: cd spec/dummy && yalc add react-on-rails
- name: Install Node modules with Yarn for dummy app
run: cd spec/dummy && yarn install --no-progress --no-emoji
- name: Dummy JS tests
run: |
cd spec/dummy
yarn run test:js
- name: Install Ruby Gems for package
run: |
bundle lock --add-platform 'x86_64-linux'
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Changes since the last non-beta release.

### Changed

- **Breaking change**: The package is ESM-only now. Please see [Release Notes](docs/release-notes/15.0.0.md#esm-only-package) for more details.
- The global context is now accessed using `globalThis`. [PR 1727](https://github.com/shakacode/react_on_rails/pull/1727) by [alexeyr-ci2](https://github.com/alexeyr-ci2).
- Generated client packs now import from `react-on-rails/client` instead of `react-on-rails`. [PR 1706](https://github.com/shakacode/react_on_rails/pull/1706) by [alexeyr-ci](https://github.com/alexeyr-ci).
- The "optimization opportunity" message when importing the server-side `react-on-rails` instead of `react-on-rails/client` in browsers is now a warning for two reasons:
Expand Down
9 changes: 9 additions & 0 deletions docs/release-notes/15.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ Major improvements to component and store hydration:
- If you were previously using `defer_generated_component_packs: false`, use `generated_component_packs_loading_strategy: :sync` instead
- For optimal performance with Shakapacker ≥ 8.2.0, consider using `generated_component_packs_loading_strategy: :async`

### ESM-only package

The package is now published as ES Modules instead of CommonJS. In most cases it shouldn't affect your code, as bundlers will be able to handle it. However:

- If you explicitly use `require('react-on-rails')`, and can't change to `import`, upgrade to Node v20.19.0+ or v22.12.0+. They allow `require` for ESM modules without any flags. Node v20.17.0+ with `--experimental-require-module` should work as well.
- If you run into `TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'.` TypeScript error, you'll need to [upgrade to TypeScript 5.8 and set `module` to `nodenext`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html#support-for-require-of-ecmascript-modules-in---module-nodenext).

Finally, if everything else fails, please contact us and we'll help you upgrade or release a dual ESM-CJS version.

### `globalThis`

[`globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis) is now used in code.
Expand Down
8 changes: 7 additions & 1 deletion eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ const config = tsEslint.config([
'jsx-a11y/anchor-is-valid': 'off',
},
},
{
files: ['node_package/**/*'],
rules: {
'import/extensions': ['error', 'ignorePackages'],
},
},
{
files: ['lib/generators/react_on_rails/templates/**/*'],
rules: {
Expand All @@ -143,7 +149,7 @@ const config = tsEslint.config([
},
},
{
files: ['**/*.ts', '**/*.tsx'],
files: ['**/*.ts{x,}', '**/*.[cm]ts'],

extends: tsEslint.configs.strictTypeChecked,

Expand Down
9 changes: 8 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
const nodeVersion = parseInt(process.version.slice(1), 10);

module.exports = {
export default {
globals: {
'ts-jest': {
tsconfig: {
rewriteRelativeImportExtensions: false,
},
},
},
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/Authenticity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuthenticityHeaders } from './types/index';
import type { AuthenticityHeaders } from './types/index.ts';

export function authenticityToken(): string | null {
const token = document.querySelector('meta[name="csrf-token"]');
Expand Down
6 changes: 3 additions & 3 deletions node_package/src/CallbackRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ItemRegistrationCallback } from './types';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle';
import { getRailsContext } from './context';
import { ItemRegistrationCallback } from './types/index.ts';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle.ts';
import { getRailsContext } from './context.ts';

/**
* Represents information about a registered item including its value,
Expand Down
26 changes: 12 additions & 14 deletions node_package/src/ClientSideRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable react/no-deprecated,@typescript-eslint/no-deprecated -- while we need to support React 16 */

import * as ReactDOM from 'react-dom';
import type { ReactElement } from 'react';
import type { RailsContext, RegisteredComponent, RenderFunction, Root } from './types';
import type { RailsContext, RegisteredComponent, RenderFunction, Root } from './types/index.ts';

import { getRailsContext, resetRailsContext } from './context';
import createReactOutput from './createReactOutput';
import { isServerRenderHash } from './isServerRenderResult';
import reactHydrateOrRender from './reactHydrateOrRender';
import { supportsRootApi } from './reactApis';
import { debugTurbolinks } from './turbolinksUtils';
import * as StoreRegistry from './StoreRegistry';
import * as ComponentRegistry from './ComponentRegistry';
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';
import * as ComponentRegistry from './ComponentRegistry.ts';

const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';

Expand Down Expand Up @@ -103,8 +101,7 @@ class ComponentRenderer {
}

// Hydrate if available and was server rendered
// @ts-expect-error potentially present if React 18 or greater
const shouldHydrate = !!(ReactDOM.hydrate || ReactDOM.hydrateRoot) && !!domNode.innerHTML;
const shouldHydrate = supportsHydrate && !!domNode.innerHTML;

const reactElementOrRouterResult = createReactOutput({
componentObj,
Expand Down Expand Up @@ -156,7 +153,8 @@ You should return a React.Component always for the client side entry point.`);
}

try {
ReactDOM.unmountComponentAtNode(domNode);
// 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(
Expand Down
6 changes: 3 additions & 3 deletions node_package/src/ComponentRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type RegisteredComponent, type ReactComponentOrRenderFunction } from './types';
import isRenderFunction from './isRenderFunction';
import CallbackRegistry from './CallbackRegistry';
import { type RegisteredComponent, type ReactComponentOrRenderFunction } from './types/index.ts';
import isRenderFunction from './isRenderFunction.ts';
import CallbackRegistry from './CallbackRegistry.ts';

const componentRegistry = new CallbackRegistry<RegisteredComponent>('component');

Expand Down
6 changes: 3 additions & 3 deletions node_package/src/RSCClientRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';
import { createFromReadableStream } from 'react-on-rails-rsc/client';
import { fetch } from './utils';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';
import { RailsContext, RenderFunction } from './types';
import { fetch } from './utils.ts';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts';
import { RailsContext, RenderFunction } from './types/index.ts';

const { use } = React;

Expand Down
4 changes: 4 additions & 0 deletions node_package/src/ReactDOMServer.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// 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';
20 changes: 10 additions & 10 deletions node_package/src/ReactOnRails.client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ReactElement } from 'react';
import * as ClientStartup from './clientStartup';
import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer';
import * as ComponentRegistry from './ComponentRegistry';
import * as StoreRegistry from './StoreRegistry';
import buildConsoleReplay from './buildConsoleReplay';
import createReactOutput from './createReactOutput';
import * as Authenticity from './Authenticity';
import * as ClientStartup from './clientStartup.ts';
import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer.ts';
import * as ComponentRegistry from './ComponentRegistry.ts';
import * as StoreRegistry from './StoreRegistry.ts';
import buildConsoleReplay from './buildConsoleReplay.ts';
import createReactOutput from './createReactOutput.ts';
import * as Authenticity from './Authenticity.ts';
import type {
RegisteredComponent,
RenderResult,
Expand All @@ -15,8 +15,8 @@ import type {
Store,
StoreGenerator,
ReactOnRailsOptions,
} from './types';
import reactHydrateOrRender from './reactHydrateOrRender';
} from './types/index.ts';
import reactHydrateOrRender from './reactHydrateOrRender.ts';

if (globalThis.ReactOnRails !== undefined) {
throw new Error(`\
Expand Down Expand Up @@ -194,5 +194,5 @@ globalThis.ReactOnRails.resetOptions();

ClientStartup.clientStartup();

export * from './types';
export * from './types/index.ts';
export default globalThis.ReactOnRails;
10 changes: 5 additions & 5 deletions node_package/src/ReactOnRails.full.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import handleError from './handleError';
import serverRenderReactComponent from './serverRenderReactComponent';
import type { RenderParams, RenderResult, ErrorOptions } from './types';
import handleError from './handleError.ts';
import serverRenderReactComponent from './serverRenderReactComponent.ts';
import type { RenderParams, RenderResult, ErrorOptions } from './types/index.ts';

import Client from './ReactOnRails.client';
import Client from './ReactOnRails.client.ts';

if (typeof window !== 'undefined') {
// warn to include a collapsed stack trace
Expand All @@ -16,5 +16,5 @@ Client.handleError = (options: ErrorOptions): string | undefined => handleError(
Client.serverRenderReactComponent = (options: RenderParams): null | string | Promise<RenderResult> =>
serverRenderReactComponent(options);

export * from './types';
export * from './types/index.ts';
export default Client;
8 changes: 4 additions & 4 deletions node_package/src/ReactOnRails.node.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ReactOnRails from './ReactOnRails.full';
import streamServerRenderedReactComponent from './streamServerRenderedReactComponent';
import ReactOnRails from './ReactOnRails.full.ts';
import streamServerRenderedReactComponent from './streamServerRenderedReactComponent.ts';

ReactOnRails.streamServerRenderedReactComponent = streamServerRenderedReactComponent;

export * from './ReactOnRails.full';
export * from './ReactOnRails.full.ts';
// eslint-disable-next-line no-restricted-exports -- see https://github.com/eslint/eslint/issues/15617
export { default } from './ReactOnRails.full';
export { default } from './ReactOnRails.full.ts';
16 changes: 8 additions & 8 deletions node_package/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { renderToPipeableStream } from 'react-on-rails-rsc/server.node';
import { PassThrough, Readable } from 'stream';

import { RSCRenderParams, StreamRenderState, StreamableComponentResult } from './types';
import ReactOnRails from './ReactOnRails.full';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import { convertToError, createResultObject } from './serverRenderUtils';
import { RSCRenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts';
import ReactOnRails from './ReactOnRails.full.ts';
import buildConsoleReplay from './buildConsoleReplay.ts';
import handleError from './handleError.ts';
import { convertToError, createResultObject } from './serverRenderUtils.ts';

import {
streamServerRenderedComponent,
transformRenderStreamChunksToResultObject,
} from './streamServerRenderedReactComponent';
import loadReactClientManifest from './loadReactClientManifest';
} from './streamServerRenderedReactComponent.ts';
import loadReactClientManifest from './loadReactClientManifest.ts';

const stringToStream = (str: string) => {
const stream = new PassThrough();
Expand Down Expand Up @@ -67,5 +67,5 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
}
};

export * from './types';
export * from './types/index.ts';
export default ReactOnRails;
4 changes: 2 additions & 2 deletions node_package/src/StoreRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import CallbackRegistry from './CallbackRegistry';
import type { Store, StoreGenerator } from './types';
import CallbackRegistry from './CallbackRegistry.ts';
import type { Store, StoreGenerator } from './types/index.ts';

const storeGeneratorRegistry = new CallbackRegistry<StoreGenerator>('store generator');
const hydratedStoreRegistry = new CallbackRegistry<Store>('hydrated store');
Expand Down
4 changes: 2 additions & 2 deletions node_package/src/buildConsoleReplay.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { wrapInScriptTags } from './RenderUtils';
import scriptSanitizedVal from './scriptSanitizedVal';
import { wrapInScriptTags } from './RenderUtils.ts';
import scriptSanitizedVal from './scriptSanitizedVal.ts';

declare global {
interface Console {
Expand Down
6 changes: 3 additions & 3 deletions node_package/src/clientStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
renderOrHydrateAllComponents,
renderOrHydrateForceLoadedComponents,
unmountAll,
} from './ClientSideRenderer';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle';
import { debugTurbolinks } from './turbolinksUtils';
} from './ClientSideRenderer.ts';
import { onPageLoaded, onPageUnloaded } from './pageLifecycle.ts';
import { debugTurbolinks } from './turbolinksUtils.ts';

export async function reactOnRailsPageLoaded() {
debugTurbolinks('reactOnRailsPageLoaded');
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactOnRailsInternal, RailsContext } from './types';
import type { ReactOnRailsInternal, RailsContext } from './types/index.ts';

declare global {
/* eslint-disable no-var,vars-on-top,no-underscore-dangle */
Expand Down
4 changes: 2 additions & 2 deletions node_package/src/createReactOutput.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import type { CreateParams, ReactComponent, RenderFunction, CreateReactOutputResult } from './types/index';
import { isServerRenderHash, isPromise } from './isServerRenderResult';
import type { CreateParams, ReactComponent, RenderFunction, CreateReactOutputResult } from './types/index.ts';
import { isServerRenderHash, isPromise } from './isServerRenderResult.ts';

function createReactElementFromRenderFunctionResult(
renderFunctionResult: ReactComponent,
Expand Down
8 changes: 4 additions & 4 deletions node_package/src/handleError.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import type { ErrorOptions } from './types/index';
import { renderToString } from './ReactDOMServer.cts';
import type { ErrorOptions } from './types/index.ts';

function handleRenderFunctionIssue(options: ErrorOptions): string {
const { e, name } = options;
Expand Down Expand Up @@ -60,8 +60,8 @@ Message: ${e.message}
${e.stack}`;

const reactElement = React.createElement('pre', null, msg);
if (typeof ReactDOMServer.renderToString === 'function') {
return ReactDOMServer.renderToString(reactElement);
if (typeof renderToString === 'function') {
return renderToString(reactElement);
}
return msg;
}
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/isRenderFunction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// See discussion:
// https://discuss.reactjs.org/t/how-to-determine-if-js-object-is-react-component/2825/2
import { ReactComponentOrRenderFunction, RenderFunction } from './types/index';
import { ReactComponentOrRenderFunction, RenderFunction } from './types/index.ts';

/**
* Used to determine we'll call be calling React.createElement on the component of if this is a
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/isServerRenderResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
ServerRenderResult,
RenderFunctionResult,
RenderStateHtml,
} from './types/index';
} from './types/index.ts';

export function isServerRenderHash(
testValue: CreateReactOutputResult | RenderFunctionResult,
Expand Down
2 changes: 1 addition & 1 deletion node_package/src/pageLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
turbolinksSupported,
turboInstalled,
turbolinksVersion5,
} from './turbolinksUtils';
} from './turbolinksUtils.ts';

type PageLifecycleCallback = () => void | Promise<void>;
type PageState = 'load' | 'unload' | 'initial';
Expand Down
Loading
Loading