diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 5d53b00f81..49a9cbe891 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -137,7 +137,7 @@ jobs: echo "Node version: "; node -v echo "pnpm version: "; pnpm --version echo "Bundler version: "; bundle --version - - name: run conversion script to support shakapacker v6 + - name: Run conversion script for older Node compatibility if: matrix.dependency-level == 'minimum' run: script/convert - name: Save root ruby gems to cache @@ -180,8 +180,24 @@ jobs: - name: Set packer version environment variable run: | echo "CI_DEPENDENCY_LEVEL=${{ matrix.dependency-level }}" >> $GITHUB_ENV - - name: Main CI - run: cd react_on_rails && bundle exec rake run_rspec:shakapacker_examples + - name: Verify rake tasks exist + run: | + cd react_on_rails + echo "Available shakapacker_examples tasks:" + bundle exec rake -T | grep shakapacker_examples || true + # Verify the specific task we need exists + TASK_NAME="run_rspec:shakapacker_examples_${{ matrix.dependency-level == 'latest' && 'latest' || 'pinned' }}" + if ! bundle exec rake -T | grep -q "$TASK_NAME"; then + echo "ERROR: Required rake task '$TASK_NAME' not found!" + exit 1 + fi + echo "✓ Found required task: $TASK_NAME" + - name: Main CI - Latest version examples + if: matrix.dependency-level == 'latest' + run: cd react_on_rails && bundle exec rake run_rspec:shakapacker_examples_latest + - name: "Main CI - Pinned version examples (React 16, 17, 18 with Shakapacker 8.2.0)" + if: matrix.dependency-level == 'minimum' + run: cd react_on_rails && bundle exec rake run_rspec:shakapacker_examples_pinned - name: Store test results uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index da229b1ec8..19347e58e7 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -140,7 +140,7 @@ jobs: echo "Node version: "; node -v echo "pnpm version: "; pnpm --version echo "Bundler version: "; bundle --version - - name: run conversion script to support shakapacker v6 + - name: Run conversion script for older Node compatibility if: matrix.dependency-level == 'minimum' run: script/convert - name: Install Node modules with pnpm for renderer package @@ -229,7 +229,7 @@ jobs: echo "Node version: "; node -v echo "pnpm version: "; pnpm --version echo "Bundler version: "; bundle --version - - name: run conversion script to support shakapacker v6 + - name: Run conversion script for older Node compatibility if: matrix.dependency-level == 'minimum' run: script/convert - name: Save root ruby gems to cache diff --git a/package.json b/package.json index f3a54ec927..f75b387d91 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "@tsconfig/node14": "^14.1.2", "@types/jest": "^29.5.14", "@types/node": "^20.17.16", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@types/turbolinks": "^5.2.2", "create-react-class": "^15.7.0", "eslint": "^9.24.0", @@ -58,8 +58,9 @@ "prettier": "^3.5.2", "prop-types": "^15.8.1", "publint": "^0.3.4", - "react": "18.0.0", - "react-dom": "18.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-on-rails-rsc": "19.0.2", "redux": "^4.2.1", "size-limit": "^12.0.0", "stylelint": "^16.14.0", diff --git a/packages/react-on-rails/src/ClientRenderer.ts b/packages/react-on-rails/src/ClientRenderer.ts index 6b97ba6fc4..ed15dbcf43 100644 --- a/packages/react-on-rails/src/ClientRenderer.ts +++ b/packages/react-on-rails/src/ClientRenderer.ts @@ -174,7 +174,6 @@ function unmountAllComponents(): void { root.unmount(); } else { // React 16-17 legacy API - // eslint-disable-next-line @typescript-eslint/no-deprecated unmountComponentAtNode(domNode); } } catch (error) { diff --git a/packages/react-on-rails/src/reactApis.cts b/packages/react-on-rails/src/reactApis.cts index d4a258b120..707b19ae7b 100644 --- a/packages/react-on-rails/src/reactApis.cts +++ b/packages/react-on-rails/src/reactApis.cts @@ -4,6 +4,14 @@ import * as ReactDOM from 'react-dom'; import type { ReactElement } from 'react'; import type { RenderReturnType } from './types/index.ts' with { 'resolution-mode': 'import' }; +// Type for legacy React DOM APIs (React 16/17) that were removed from @types/react-dom@19 +// These are only used at runtime when supportsRootApi is false +interface LegacyReactDOM { + hydrate(element: ReactElement, container: Element): void; + render(element: ReactElement, container: Element): RenderReturnType; + unmountComponentAtNode(container: Element): boolean; +} + const reactMajorVersion = Number(ReactDOM.version?.split('.')[0]) || 16; // TODO: once we require React 18, we can remove this and inline everything guarded by it. @@ -29,12 +37,27 @@ if (supportsRootApi) { 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 - */ +// Cast ReactDOM to include legacy APIs for React 16/17 compatibility +// These methods exist at runtime but are removed from @types/react-dom@19 +const legacyReactDOM = ReactDOM as unknown as LegacyReactDOM; + +// Validate legacy APIs exist at runtime when needed (React < 18) +if (!supportsRootApi) { + if (typeof legacyReactDOM.hydrate !== 'function') { + throw new Error('React legacy hydrate API not available. Expected React 16/17.'); + } + if (typeof legacyReactDOM.render !== 'function') { + throw new Error('React legacy render API not available. Expected React 16/17.'); + } + if (typeof legacyReactDOM.unmountComponentAtNode !== 'function') { + throw new Error('React legacy unmountComponentAtNode API not available. Expected React 16/17.'); + } +} + +/* eslint-disable @typescript-eslint/no-non-null-assertion -- reactDomClient is always defined when supportsRootApi is true */ export const reactHydrate: HydrateOrRenderType = supportsRootApi ? reactDomClient!.hydrateRoot - : (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode); + : (domNode, reactElement) => legacyReactDOM.hydrate(reactElement, domNode); export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType { if (supportsRootApi) { @@ -43,14 +66,14 @@ export function reactRender(domNode: Element, reactElement: ReactElement): Rende return root; } - // eslint-disable-next-line react/no-render-return-value - return ReactDOM.render(reactElement, domNode); + return legacyReactDOM.render(reactElement, domNode); } -export const unmountComponentAtNode: typeof ReactDOM.unmountComponentAtNode = supportsRootApi +export const unmountComponentAtNode: (container: Element) => boolean = supportsRootApi ? // not used if we use root API - () => false - : ReactDOM.unmountComponentAtNode; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_container: Element) => false + : (container: Element) => legacyReactDOM.unmountComponentAtNode(container); export const ensureReactUseAvailable = () => { if (!('use' in React) || typeof React.use !== 'function') { diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index 22f5b962bd..98901ed470 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -125,9 +125,9 @@ interface ServerRenderResult { error?: Error; } -type CreateReactOutputSyncResult = ServerRenderResult | ReactElement; +type CreateReactOutputSyncResult = ServerRenderResult | ReactElement; -type CreateReactOutputAsyncResult = Promise>; +type CreateReactOutputAsyncResult = Promise; type CreateReactOutputResult = CreateReactOutputSyncResult | CreateReactOutputAsyncResult; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e47b2c184e..b7db392c03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.2.0 - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tsconfig/node14': specifier: ^14.1.2 version: 14.1.8 @@ -67,11 +67,11 @@ importers: specifier: ^20.17.16 version: 20.19.25 '@types/react': - specifier: ^18.3.18 - version: 18.3.27 + specifier: ^19.0.0 + version: 19.2.7 '@types/react-dom': - specifier: ^18.3.5 - version: 18.3.7(@types/react@18.3.27) + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.7) '@types/turbolinks': specifier: ^5.2.2 version: 5.2.2 @@ -98,7 +98,7 @@ importers: version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jest: specifier: ^28.11.0 - version: 28.14.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 28.14.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(jest@29.7.0(@types/node@20.19.25))(typescript@5.9.3) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@9.39.1(jiti@2.6.1)) @@ -119,7 +119,7 @@ importers: version: 16.5.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + version: 29.7.0(@types/node@20.19.25) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -148,11 +148,14 @@ importers: specifier: ^0.3.4 version: 0.3.15 react: - specifier: 18.0.0 - version: 18.0.0 + specifier: ^19.0.0 + version: 19.2.0 react-dom: - specifier: 18.0.0 - version: 18.0.0(react@18.0.0) + specifier: ^19.0.0 + version: 19.2.0(react@19.2.0) + react-on-rails-rsc: + specifier: 19.0.2 + version: 19.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(webpack@5.103.0(@swc/core@1.15.3)) redux: specifier: ^4.2.1 version: 4.2.1 @@ -170,7 +173,7 @@ importers: version: 0.2.6(@swc/core@1.15.3)(webpack@5.103.0(@swc/core@1.15.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25))(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -1647,28 +1650,19 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} - - '@types/parse-json@4.0.2': - resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - '@types/react': ^18.0.0 + '@types/react': ^19.2.0 - '@types/react@18.3.27': - resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -2137,10 +2131,6 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - babel-plugin-macros@3.1.0: - resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} - engines: {node: '>=10', npm: '>=6'} - babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -2406,10 +2396,6 @@ packages: core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} - cosmiconfig@7.1.0: - resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} - engines: {node: '>=10'} - cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -4414,11 +4400,6 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - react-dom@18.0.0: - resolution: {integrity: sha512-XqX7uzmFo0pUceWFCt7Gff6IyIMzFUn7QMZrbrQfGxtaxXZIcGQzoNpRLE3fQLnS4XzLLPMZX2T9TRcSrasicw==} - peerDependencies: - react: ^18.0.0 - react-dom@19.2.0: resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} peerDependencies: @@ -4433,6 +4414,13 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-on-rails-rsc@19.0.2: + resolution: {integrity: sha512-0q26jcWcr6v9nfYfB4wxtAdTwEC4PCDSb/5U7TPperP4Ac9U2K7nt3uLOSVh7BX4bacX3PrpDeI1C30cIkBPog==} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + webpack: ^5.59.0 + react-on-rails-rsc@19.0.3: resolution: {integrity: sha512-g+89U83+WjZDbtLvYQbjld0pWdUXpKageSoeKsX8cj1SkmULMAzbxgvH6vdzOuQUSwchkbDgwFO9umlHDhiyug==} peerDependencies: @@ -4440,10 +4428,6 @@ packages: react-dom: ^19.0.1 webpack: ^5.59.0 - react@18.0.0: - resolution: {integrity: sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==} - engines: {node: '>=0.10.0'} - react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -4589,9 +4573,6 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.21.0: - resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5115,9 +5096,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -5361,10 +5339,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - yargs-parser@15.0.3: resolution: {integrity: sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==} @@ -6417,7 +6391,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)': + '@jest/core@29.7.0': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -6431,7 +6405,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@20.19.25) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -6891,15 +6865,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 '@testing-library/dom': 10.4.1 - react: 18.0.0 - react-dom: 18.0.0(react@18.0.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) '@tootallnate/once@1.1.2': {} @@ -6942,11 +6916,11 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/connect@3.4.38': dependencies: - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/eslint-scope@3.7.7': dependencies: @@ -6962,7 +6936,7 @@ snapshots: '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -7032,41 +7006,31 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.1': - dependencies: - undici-types: 7.16.0 - - '@types/parse-json@4.0.2': - optional: true - - '@types/prop-types@15.7.15': {} - '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.7(@types/react@18.3.27)': + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.7 - '@types/react@18.3.27': + '@types/react@19.2.7': dependencies: - '@types/prop-types': 15.7.15 csstype: 3.2.3 '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/send@1.2.1': dependencies: - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.10.1 + '@types/node': 20.19.25 '@types/send': 0.17.6 '@types/stack-utils@2.0.3': {} @@ -7565,13 +7529,6 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-plugin-macros@3.1.0: - dependencies: - '@babel/runtime': 7.28.4 - cosmiconfig: 7.1.0 - resolve: 1.22.11 - optional: true - babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.5): dependencies: '@babel/compat-data': 7.28.5 @@ -7846,15 +7803,6 @@ snapshots: dependencies: browserslist: 4.28.0 - cosmiconfig@7.1.0: - dependencies: - '@types/parse-json': 4.0.2 - import-fresh: 3.3.1 - parse-json: 5.2.0 - path-type: 4.0.0 - yaml: 1.10.2 - optional: true - cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -7864,13 +7812,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0): + create-jest@29.7.0(@types/node@20.19.25): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@20.19.25) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -7987,9 +7935,7 @@ snapshots: decimal.js@10.6.0: {} - dedent@1.7.0(babel-plugin-macros@3.1.0): - optionalDependencies: - babel-plugin-macros: 3.1.0 + dedent@1.7.0: {} deep-is@0.1.4: {} @@ -8336,13 +8282,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@28.14.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0))(typescript@5.9.3): + eslint-plugin-jest@28.14.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(jest@29.7.0(@types/node@20.19.25))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) optionalDependencies: '@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - jest: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest: 29.7.0(@types/node@20.19.25) transitivePeerDependencies: - supports-color - typescript @@ -9187,7 +9133,7 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0(babel-plugin-macros@3.1.0): + jest-circus@29.7.0: dependencies: '@jest/environment': 29.7.0 '@jest/expect': 29.7.0 @@ -9196,7 +9142,7 @@ snapshots: '@types/node': 20.19.25 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.0(babel-plugin-macros@3.1.0) + dedent: 1.7.0 is-generator-fn: 2.1.0 jest-each: 29.7.0 jest-matcher-utils: 29.7.0 @@ -9213,16 +9159,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0): + jest-cli@29.7.0(@types/node@20.19.25): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0) + '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + create-jest: 29.7.0(@types/node@20.19.25) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest-config: 29.7.0(@types/node@20.19.25) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -9232,7 +9178,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0): + jest-config@29.7.0(@types/node@20.19.25): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -9243,7 +9189,7 @@ snapshots: deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 @@ -9501,7 +9447,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.1 + '@types/node': 20.19.25 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -9512,12 +9458,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0): + jest@29.7.0(@types/node@20.19.25): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0) + '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest-cli: 29.7.0(@types/node@20.19.25) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -10355,12 +10301,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - react-dom@18.0.0(react@18.0.0): - dependencies: - loose-envify: 1.4.0 - react: 18.0.0 - scheduler: 0.21.0 - react-dom@19.2.0(react@19.2.0): dependencies: react: 19.2.0 @@ -10372,7 +10312,7 @@ snapshots: react-is@18.3.1: {} - react-on-rails-rsc@19.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(webpack@5.103.0(@swc/core@1.15.3)): + react-on-rails-rsc@19.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(webpack@5.103.0(@swc/core@1.15.3)): dependencies: acorn-loose: 8.5.2 neo-async: 2.6.2 @@ -10381,9 +10321,14 @@ snapshots: webpack: 5.103.0(@swc/core@1.15.3) webpack-sources: 3.3.3 - react@18.0.0: + react-on-rails-rsc@19.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(webpack@5.103.0(@swc/core@1.15.3)): dependencies: - loose-envify: 1.4.0 + acorn-loose: 8.5.2 + neo-async: 2.6.2 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + webpack: 5.103.0(@swc/core@1.15.3) + webpack-sources: 3.3.3 react@19.2.0: {} @@ -10534,10 +10479,6 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.21.0: - dependencies: - loose-envify: 1.4.0 - scheduler@0.27.0: {} schema-utils@4.3.3: @@ -11049,12 +10990,12 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.25))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + jest: 29.7.0(@types/node@20.19.25) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -11154,8 +11095,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} - unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-emoji-modifier-base@1.0.0: {} @@ -11421,9 +11360,6 @@ snapshots: yallist@3.1.1: {} - yaml@1.10.2: - optional: true - yargs-parser@15.0.3: dependencies: camelcase: 5.3.1 diff --git a/react_on_rails/lib/generators/react_on_rails/dev_tests_generator.rb b/react_on_rails/lib/generators/react_on_rails/dev_tests_generator.rb index 253a2e2476..fd63c05867 100644 --- a/react_on_rails/lib/generators/react_on_rails/dev_tests_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/dev_tests_generator.rb @@ -31,7 +31,8 @@ def copy_tests def add_test_related_gems_to_gemfile gem("rspec-rails", group: :test) - gem("chromedriver-helper", group: :test) + # NOTE: chromedriver-helper was deprecated in 2019. Modern selenium-webdriver (4.x) + # and GitHub Actions have built-in driver management, so no driver helper is needed. gem("coveralls", require: false) end diff --git a/react_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/react_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index 8c4fd1863c..34202cacd6 100644 --- a/react_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/react_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -9,11 +9,10 @@ module ServerRenderingPool class RubyEmbeddedJavaScript class << self def reset_pool - options = { + @js_context_pool = ConnectionPool.new( size: ReactOnRails.configuration.server_renderer_pool_size, timeout: ReactOnRails.configuration.server_renderer_timeout - } - @js_context_pool = ConnectionPool.new(options) { create_js_context } + ) { create_js_context } end def reset_pool_if_server_bundle_was_modified diff --git a/react_on_rails/lib/react_on_rails/utils.rb b/react_on_rails/lib/react_on_rails/utils.rb index 264feaae49..4737ec621d 100644 --- a/react_on_rails/lib/react_on_rails/utils.rb +++ b/react_on_rails/lib/react_on_rails/utils.rb @@ -98,9 +98,12 @@ def self.invoke_and_exit_if_failed(cmd, failure_message) exitstatus: #{status.exitstatus}#{stdout_msg}#{stderr_msg} MSG - puts wrap_message(msg) - puts "" - puts default_troubleshooting_section + # Use warn to ensure output is visible in CI logs (goes to stderr) + # and flush immediately before calling exit! + warn wrap_message(msg) + warn "" + warn default_troubleshooting_section + $stderr.flush # Rspec catches exit without! in the exit callbacks exit!(1) diff --git a/react_on_rails/rakelib/example_type.rb b/react_on_rails/rakelib/example_type.rb index 85e0fe3734..540f80fa14 100644 --- a/react_on_rails/rakelib/example_type.rb +++ b/react_on_rails/rakelib/example_type.rb @@ -14,12 +14,49 @@ def self.all @all ||= { shakapacker_examples: [] } end - attr_reader :packer_type, :name, :generator_options + # Supported React versions for compatibility testing + # Keys are major version strings, values are specific version to pin to (nil = latest) + REACT_VERSIONS = { + "16" => "16.14.0", + "17" => "17.0.2", + "18" => "18.0.0", + "19" => nil # nil means use latest (default) + }.freeze + + # Supported React major versions (we test with latest patch of each) + MINIMUM_SUPPORTED_REACT_MAJOR_VERSION = "16" + LATEST_REACT_MAJOR_VERSION = "19" + + # Minimum Shakapacker version for compatibility testing + MINIMUM_SHAKAPACKER_VERSION = "8.2.0" + + attr_reader :packer_type, :name, :generator_options, :react_version + + # Returns true if this example uses a pinned (non-latest) React version + def pinned_react_version? + !react_version.nil? + end + + # Returns the actual React version string to use + def react_version_string + return nil unless react_version + + REACT_VERSIONS[react_version.to_s] || react_version + end - def initialize(packer_type: nil, name: nil, generator_options: nil) + def initialize(packer_type: nil, name: nil, generator_options: nil, react_version: nil) @packer_type = packer_type @name = name @generator_options = generator_options + @react_version = react_version + + # Validate react_version is a known version to catch configuration errors early + if @react_version && !REACT_VERSIONS.key?(@react_version.to_s) + valid_versions = REACT_VERSIONS.keys.join(", ") + raise ArgumentError, "Invalid react_version '#{@react_version}' for example '#{name}'. " \ + "Valid versions: #{valid_versions}" + end + self.class.all[packer_type.to_sym] << self end diff --git a/react_on_rails/rakelib/examples_config.yml b/react_on_rails/rakelib/examples_config.yml index 731c9d9725..c4c569a6fc 100644 --- a/react_on_rails/rakelib/examples_config.yml +++ b/react_on_rails/rakelib/examples_config.yml @@ -1,4 +1,23 @@ +# Example Type Configuration for React on Rails Generator Tests +# +# CI Test Coverage: +# ----------------- +# - Latest CI (all PRs): Runs shakapacker_examples_latest (React 19, Shakapacker 9.x) +# Examples: basic, basic-server-rendering, redux, redux-server-rendering +# +# - Pinned CI (master): Runs shakapacker_examples_pinned (React 16, 17, 18 with Shakapacker 8.2.0) +# Examples: basic-react16, basic-server-rendering-react16, +# basic-react17, basic-server-rendering-react17, +# basic-react18, basic-server-rendering-react18 +# +# Terminology: +# - "Latest" = Current React version (19) with latest Shakapacker (9.x) +# - "Pinned" = Specific older React versions (16, 17, 18) for backward compatibility testing +# +# Note: We support React 16+ but test with latest patch of each major version. + example_type_data: + # Latest versions (React 19, Shakapacker 9.x) - name: basic generator_options: '' - name: basic-server-rendering @@ -7,3 +26,27 @@ example_type_data: generator_options: --redux - name: redux-server-rendering generator_options: --redux --example-server-rendering + + # React 18 compatibility tests (uses Root API introduced in React 18) + - name: basic-react18 + generator_options: '' + react_version: '18' + - name: basic-server-rendering-react18 + generator_options: --example-server-rendering + react_version: '18' + + # React 17 compatibility tests (legacy render/hydrate API) + - name: basic-react17 + generator_options: '' + react_version: '17' + - name: basic-server-rendering-react17 + generator_options: --example-server-rendering + react_version: '17' + + # React 16 compatibility tests (oldest supported legacy API) + - name: basic-react16 + generator_options: '' + react_version: '16' + - name: basic-server-rendering-react16 + generator_options: --example-server-rendering + react_version: '16' diff --git a/react_on_rails/rakelib/run_rspec.rake b/react_on_rails/rakelib/run_rspec.rake index a61db47c8c..869f34b812 100644 --- a/react_on_rails/rakelib/run_rspec.rake +++ b/react_on_rails/rakelib/run_rspec.rake @@ -82,7 +82,10 @@ namespace :run_rspec do puts "Creating #{example_type.rspec_task_name} task" desc "Runs RSpec for #{example_type.name_pretty} only" task example_type.rspec_task_name_short => example_type.gen_task_name do - run_tests_in(File.join(examples_dir, example_type.name)) # have to use relative path + # Use unbundled mode for pinned React version examples to ensure the example app's + # Gemfile and gem versions are used, not the parent workspace's bundle + run_tests_in(File.join(examples_dir, example_type.name), + unbundled: example_type.pinned_react_version?) end end @@ -91,6 +94,52 @@ namespace :run_rspec do ExampleType.all[:shakapacker_examples].each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } end + # Helper methods for filtering examples by React version + def latest_examples + ExampleType.all[:shakapacker_examples].reject(&:pinned_react_version?) + end + + def react18_examples + ExampleType.all[:shakapacker_examples].select { |e| e.react_version == "18" } + end + + def react17_examples + ExampleType.all[:shakapacker_examples].select { |e| e.react_version == "17" } + end + + def react16_examples + ExampleType.all[:shakapacker_examples].select { |e| e.react_version == "16" } + end + + def pinned_version_examples + ExampleType.all[:shakapacker_examples].select(&:pinned_react_version?) + end + + desc "Runs Rspec for latest version example apps only (React 19, Shakapacker 9.x)" + task shakapacker_examples_latest: latest_examples.map(&:gen_task_name) do + latest_examples.each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } + end + + desc "Runs Rspec for React 18 example apps only (Shakapacker 8.2.0)" + task shakapacker_examples_react18: react18_examples.map(&:gen_task_name) do + react18_examples.each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } + end + + desc "Runs Rspec for React 17 example apps only (legacy render API)" + task shakapacker_examples_react17: react17_examples.map(&:gen_task_name) do + react17_examples.each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } + end + + desc "Runs Rspec for React 16 example apps only (oldest supported legacy API)" + task shakapacker_examples_react16: react16_examples.map(&:gen_task_name) do + react16_examples.each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } + end + + desc "Runs Rspec for all pinned version example apps (React 16, 17, and 18)" + task shakapacker_examples_pinned: pinned_version_examples.map(&:gen_task_name) do + pinned_version_examples.each { |example_type| Rake::Task[example_type.rspec_task_name].invoke } + end + Coveralls::RakeTask.new if ENV["USE_COVERALLS"] == "TRUE" desc "run all tests no examples" @@ -139,11 +188,23 @@ end # If string is passed and it's not absolute, it's converted relative to root of the gem. # TEST_ENV_COMMAND_NAME is used to make SimpleCov.command_name unique in order to # prevent a name collision. Defaults to the given directory's name. +# Options: +# :command_name - name for SimpleCov (default: dir basename) +# :rspec_args - additional rspec arguments (default: "") +# :env_vars - additional environment variables (default: "") +# :unbundled - run with unbundled_sh_in_dir for Bundler isolation (default: false) +# This is required for pinned version examples because they have different +# gem versions (e.g., Shakapacker 8.2.0) pinned in their Gemfile than the +# parent workspace (Shakapacker 9.x). Without bundle isolation, Bundler +# would inherit the parent's gem resolution and use the wrong versions. +# Latest version examples don't need this because they use the same versions +# as the parent workspace. def run_tests_in(dir, options = {}) path = calc_path(dir) command_name = options.fetch(:command_name, path.basename) rspec_args = options.fetch(:rspec_args, "") + unbundled = options.fetch(:unbundled, false) # Build environment variables as an array for proper spacing env_tokens = [] @@ -152,5 +213,11 @@ def run_tests_in(dir, options = {}) env_tokens << "COVERAGE=true" if ENV["USE_COVERALLS"] env_vars = env_tokens.join(" ") - sh_in_dir(path.realpath, "#{env_vars} bundle exec rspec #{rspec_args}") + command = "#{env_vars} bundle exec rspec #{rspec_args}" + + if unbundled + unbundled_sh_in_dir(path.realpath, command) + else + sh_in_dir(path.realpath, command) + end end diff --git a/react_on_rails/rakelib/shakapacker_examples.rake b/react_on_rails/rakelib/shakapacker_examples.rake index 5f5f1bb864..3431471084 100644 --- a/react_on_rails/rakelib/shakapacker_examples.rake +++ b/react_on_rails/rakelib/shakapacker_examples.rake @@ -8,6 +8,7 @@ require "yaml" require "rails/version" require "pathname" +require "json" require_relative "example_type" require_relative "task_helpers" @@ -15,8 +16,89 @@ require_relative "task_helpers" namespace :shakapacker_examples do # rubocop:disable Metrics/BlockLength include ReactOnRails::TaskHelpers + # Updates React-related dependencies to a specific version + def update_react_dependencies(deps, react_version) + return unless deps + + deps["react"] = react_version + deps["react-dom"] = react_version + end + + # Updates Shakapacker to minimum supported version in either dependencies or devDependencies + def update_shakapacker_dependency(deps, dev_deps) + if dev_deps&.key?("shakapacker") + dev_deps["shakapacker"] = ExampleType::MINIMUM_SHAKAPACKER_VERSION + elsif deps&.key?("shakapacker") + deps["shakapacker"] = ExampleType::MINIMUM_SHAKAPACKER_VERSION + end + end + + # Updates dependencies in package.json to use specific React version + def update_package_json_for_react_version(package_json_path, react_version) + return unless File.exist?(package_json_path) + + begin + package_json = JSON.parse(File.read(package_json_path)) + rescue JSON::ParserError => e + puts " ERROR: Failed to parse #{package_json_path}: #{e.message}" + raise + end + + deps = package_json["dependencies"] + dev_deps = package_json["devDependencies"] + + update_react_dependencies(deps, react_version) + # Shakapacker 8.2.0 requires webpack-assets-manifest ^5.x (v6.x uses ESM and breaks) + # Always add this explicitly since the transitive dependency from shakapacker may be v6.x + dev_deps["webpack-assets-manifest"] = "^5.0.6" if dev_deps + # Shakapacker 8.2.0 requires babel-loader to be explicitly installed as a devDependency + # (in 9.x this requirement was relaxed or the package structure changed) + dev_deps["babel-loader"] = "^9.1.3" if dev_deps + # @babel/plugin-transform-runtime is required by the default babel config but not + # automatically included as a dependency in older Shakapacker versions + dev_deps["@babel/plugin-transform-runtime"] = "^7.24.0" if dev_deps + update_shakapacker_dependency(deps, dev_deps) + + # Add npm overrides to force specific React version, preventing yalc-linked + # react-on-rails from pulling in React 19 as a transitive dependency + package_json["overrides"] = { + "react" => react_version, + "react-dom" => react_version + } + + File.write(package_json_path, "#{JSON.pretty_generate(package_json)}\n") + end + + # Updates Gemfile to pin shakapacker to minimum version + # (must match the npm package version exactly) + def update_gemfile_versions(gemfile_path) + return unless File.exist?(gemfile_path) + + gemfile_content = File.read(gemfile_path) + # Replace any shakapacker gem line with exact version pin + # Handle both single-line: gem 'shakapacker', '>= 8.2.0' + # And multi-line declarations: + # gem 'shakapacker', + # '>= 8.2.0' + gemfile_content = gemfile_content.gsub( + /gem ['"]shakapacker['"][^\n]*(?:\n\s+[^g\n][^\n]*)*$/m, + "gem 'shakapacker', '#{ExampleType::MINIMUM_SHAKAPACKER_VERSION}'" + ) + File.write(gemfile_path, gemfile_content) + end + + # Updates package.json and Gemfile to use specific React version for compatibility testing + def apply_react_version(dir, react_version) + update_package_json_for_react_version(File.join(dir, "package.json"), react_version) + update_gemfile_versions(File.join(dir, "Gemfile")) + + puts " Updated package.json for compatibility testing:" + puts " React: #{react_version}" + puts " Shakapacker: #{ExampleType::MINIMUM_SHAKAPACKER_VERSION}" + end + # Define tasks for each example type - ExampleType.all[:shakapacker_examples].each do |example_type| + ExampleType.all[:shakapacker_examples].each do |example_type| # rubocop:disable Metrics/BlockLength relative_gem_root = Pathname(gem_root).relative_path_from(Pathname(example_type.dir)) # CLOBBER desc "Clobbers (deletes) #{example_type.name_pretty}" @@ -46,10 +128,31 @@ namespace :shakapacker_examples do # rubocop:disable Metrics/BlockLength "REACT_ON_RAILS_SKIP_VALIDATION=true #{cmd}" end sh_in_dir(example_type.dir, generator_commands) - sh_in_dir(example_type.dir, "npm install") + # Re-run bundle install since dev_tests generator adds rspec-rails and coveralls to Gemfile + bundle_install_in(example_type.dir) + + # Apply specific React version for compatibility testing examples + if example_type.pinned_react_version? + apply_react_version(example_type.dir, example_type.react_version_string) + # Re-run bundle install since Gemfile was updated with pinned shakapacker version + bundle_install_in(example_type.dir) + # Run npm install BEFORE shakapacker:binstubs to ensure the npm shakapacker version + # matches the gem version. The binstubs task loads the Rails environment which + # validates version matching between gem and npm package. + # Use --legacy-peer-deps to avoid peer dependency conflicts when yalc-linked + # react-on-rails expects newer React versions + sh_in_dir(example_type.dir, "npm install --legacy-peer-deps") + # Regenerate Shakapacker binstubs after downgrading from 9.x to 8.2.x + # The binstub format may differ between major versions + unbundled_sh_in_dir(example_type.dir, "bundle exec rake shakapacker:binstubs") + else + sh_in_dir(example_type.dir, "npm install") + end # Generate the component packs after running the generator to ensure all - # auto-bundled components have corresponding pack files created - sh_in_dir(example_type.dir, "bundle exec rake react_on_rails:generate_packs") + # auto-bundled components have corresponding pack files created. + # Use unbundled_sh_in_dir to ensure we're using the generated app's Gemfile + # and gem versions, not the parent workspace's bundle context. + unbundled_sh_in_dir(example_type.dir, "bundle exec rake react_on_rails:generate_packs") end end diff --git a/react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx b/react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx index b7ab770286..a96f624be7 100644 --- a/react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx +++ b/react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx @@ -5,7 +5,7 @@ import React from 'react'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; -import thunkMiddleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import ReactDOM from 'react-dom'; import reducers from '../../app/reducers/reducersIndex'; @@ -29,7 +29,7 @@ export default (props, railsContext, domNodeId) => { // 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)); + const store = createStore(combinedReducer, combinedProps, applyMiddleware(thunk)); // renderApp is a function required for hot reloading. see // https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js diff --git a/react_on_rails/spec/dummy/client/app/components/ReactHelmet.jsx b/react_on_rails/spec/dummy/client/app/components/ReactHelmet.jsx index bba49492df..ca82611935 100644 --- a/react_on_rails/spec/dummy/client/app/components/ReactHelmet.jsx +++ b/react_on_rails/spec/dummy/client/app/components/ReactHelmet.jsx @@ -1,7 +1,10 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@dr.pogodin/react-helmet'; import HelloWorld from '../startup/HelloWorld'; +// Note: This component expects to be wrapped in a HelmetProvider by its parent. +// For client-side rendering, wrap in HelmetProvider at the app root. +// For server-side rendering, the server entry point provides the HelmetProvider. const ReactHelmet = (props) => (
diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx index 2c5f342d92..4dd6c26570 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx @@ -1,11 +1,17 @@ // Top level component for simple client side only rendering import React from 'react'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; // This works fine, React functional component: // export default (props) => ; -export default (props) => ; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering +export default (props) => ( + + + +); // Note, the server side has to be a Render-Function diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx index f7c77e8a73..6546457bca 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx @@ -1,8 +1,8 @@ // Top level component for simple client side only rendering import React from 'react'; import { renderToString } from 'react-dom/server'; -import { Helmet } from 'react-helmet'; -import ReactHelmet from '../components/ReactHelmet'; +import { Helmet, HelmetProvider } from '@dr.pogodin/react-helmet'; +import HelloWorld from './HelloWorld'; /* * Export a function that takes the props and returns an object with { renderedHtml } @@ -16,12 +16,27 @@ import ReactHelmet from '../components/ReactHelmet'; * the function could get the property of `.renderFunction = true` added to it. */ export default (props, _railsContext) => { - const componentHtml = renderToString(); - const helmet = Helmet.renderStatic(); + // For server-side rendering with @dr.pogodin/react-helmet, we pass a context object + // to HelmetProvider to capture the helmet data per-request (thread-safe) + const helmetContext = {}; + + const componentHtml = renderToString( + +
+ + Custom page title + + Props: {JSON.stringify(props)} + +
+
, + ); + + const { helmet } = helmetContext; const renderedHtml = { componentHtml, - title: helmet.title.toString(), + title: helmet ? helmet.title.toString() : '', }; // Note that this function returns an Object for server rendering. diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx index 2c5f342d92..4dd6c26570 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx @@ -1,11 +1,17 @@ // Top level component for simple client side only rendering import React from 'react'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; // This works fine, React functional component: // export default (props) => ; -export default (props) => ; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering +export default (props) => ( + + + +); // Note, the server side has to be a Render-Function diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx index d1c72af50d..f0d68d3e18 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx @@ -3,7 +3,7 @@ // function. The point of this is to provide a good error. import React from 'react'; import { renderToString } from 'react-dom/server'; -import { Helmet } from 'react-helmet'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; /* @@ -18,12 +18,17 @@ import ReactHelmet from '../components/ReactHelmet'; * Alternately, the function could get the property of `.renderFunction = true` added to it. */ export default (props) => { - const componentHtml = renderToString(); - const helmet = Helmet.renderStatic(); + const helmetContext = {}; + const componentHtml = renderToString( + + + , + ); + const { helmet } = helmetContext; const renderedHtml = { componentHtml, - title: helmet.title.toString(), + title: helmet ? helmet.title.toString() : '', }; return { renderedHtml }; }; diff --git a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx index 3e8ddc211b..cfe9e24a93 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx @@ -5,7 +5,7 @@ import React from 'react'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; -import thunkMiddleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import ReactDOMClient from 'react-dom/client'; import reducers from '../reducers/reducersIndex'; @@ -34,7 +34,7 @@ export default (props, railsContext, domNodeId) => { // 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)); + const store = createStore(combinedReducer, combinedProps, applyMiddleware(thunk)); // renderApp is a function required for hot reloading. see // https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js diff --git a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx index bdcc317962..a26b2969f0 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx @@ -6,7 +6,7 @@ import React from 'react'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; -import middleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; // Uses the index import reducers from '../reducers/reducersIndex'; @@ -28,7 +28,7 @@ export default (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 = applyMiddleware(middleware)(createStore)(combinedReducer, combinedProps); + const store = applyMiddleware(thunk)(createStore)(combinedReducer, combinedProps); // Provider uses the this.props.children, so we're not typical React syntax. // This allows redux to add additional props to the HelloWorldContainer. diff --git a/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx b/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx index 33dcc680ed..3d463ff891 100644 --- a/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx +++ b/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx @@ -1,5 +1,5 @@ import { combineReducers, applyMiddleware, createStore } from 'redux'; -import middleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import reducers from '../reducers/reducersIndex'; @@ -12,5 +12,5 @@ export default (props, railsContext) => { delete props.prerender; const combinedReducer = combineReducers(reducers); const newProps = { ...props, railsContext }; - return applyMiddleware(middleware)(createStore)(combinedReducer, newProps); + return applyMiddleware(thunk)(createStore)(combinedReducer, newProps); }; diff --git a/react_on_rails/spec/dummy/package.json b/react_on_rails/spec/dummy/package.json index e9998a52db..16f3f804fc 100644 --- a/react_on_rails/spec/dummy/package.json +++ b/react_on_rails/spec/dummy/package.json @@ -18,14 +18,14 @@ "node-libs-browser": "^2.2.1", "null-loader": "^4.0.0", "prop-types": "^15.7.2", - "react": "18.0.0", - "react-dom": "18.0.0", - "react-helmet": "^6.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@dr.pogodin/react-helmet": "^3.0.4", "react-on-rails": "link:.yalc/react-on-rails", - "react-redux": "^8.0.2", + "react-redux": "^9.2.0", "react-router-dom": "^6.0.0", - "redux": "^4.0.1", - "redux-thunk": "^2.2.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", "regenerator-runtime": "^0.13.4" }, "devDependencies": { @@ -38,7 +38,6 @@ "@rescript/react": "^0.13.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/react-helmet": "^6.1.5", "babel-loader": "8.2.4", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "compression-webpack-plugin": "9", diff --git a/react_on_rails/spec/react_on_rails/generators/dev_tests_generator_spec.rb b/react_on_rails/spec/react_on_rails/generators/dev_tests_generator_spec.rb index 5a100a5dba..e13ced2178 100644 --- a/react_on_rails/spec/react_on_rails/generators/dev_tests_generator_spec.rb +++ b/react_on_rails/spec/react_on_rails/generators/dev_tests_generator_spec.rb @@ -34,7 +34,8 @@ assert_file("Gemfile") do |contents| expect(contents).to match("gem \"rspec-rails\", group: :test") expect(contents).to match("gem \"coveralls\", require: false") - expect(contents).to match("gem \"chromedriver-helper\", group: :test") + # chromedriver-helper was removed as it's deprecated since 2019 + # Modern selenium-webdriver (4.x) handles driver management automatically end end end diff --git a/react_on_rails/spec/react_on_rails/test_helper/webpack_assets_compiler_spec.rb b/react_on_rails/spec/react_on_rails/test_helper/webpack_assets_compiler_spec.rb index 049ebb5061..aa8340b4ad 100644 --- a/react_on_rails/spec/react_on_rails/test_helper/webpack_assets_compiler_spec.rb +++ b/react_on_rails/spec/react_on_rails/test_helper/webpack_assets_compiler_spec.rb @@ -35,7 +35,7 @@ described_class.new.compile_assets rescue SystemExit # No op - end.to output(/#{expected_output}/).to_stdout + end.to output(/#{expected_output}/).to_stderr end end end diff --git a/script/convert b/script/convert index 0ea3ea0b07..ce4d7d3489 100755 --- a/script/convert +++ b/script/convert @@ -1,15 +1,40 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# This script converts the codebase to use minimum supported dependency versions. +# It's run during CI when testing the "minimum" dependency matrix to ensure +# backward compatibility. +# +# React/Shakapacker version compatibility is now tested through generator smoke tests +# (see examples_config.yml for minimum version examples like basic-minimum). +# This script only handles Node.js/tooling compatibility and Pro package test adjustments. + def gsub_file_content(path, old_content, new_content) path = File.expand_path(path, __dir__) # Support both old structure (files at root) and new structure (files in react_on_rails/) # This allows the script to work before and after the monorepo reorganization unless File.exist?(path) - # Try the old location by removing one level of nesting - old_path = path.sub(%r{/react_on_rails/Gemfile}, "/Gemfile") - path = old_path if File.exist?(old_path) + # Try alternate locations: + # 1. For react_on_rails subdirectory paths, try removing that nesting + # 2. For packages/* paths, try the old node_package/ location + alternate_paths = [ + path.sub(%r{/react_on_rails/}, "/"), + path.sub(%r{/packages/react-on-rails/}, "/node_package/"), + path.sub(%r{/packages/react-on-rails-pro/}, "/react_on_rails_pro/node_package/") + ] + + alternate_paths.each do |alt_path| + if File.exist?(alt_path) + path = alt_path + break + end + end + end + + unless File.exist?(path) + warn "Warning: File not found: #{path}" + return end content = File.binread(path) @@ -17,19 +42,6 @@ def gsub_file_content(path, old_content, new_content) File.binwrite(path, content) end -def move(old_path, new_path) - old_path = File.expand_path(old_path, __dir__) - new_path = File.expand_path(new_path, __dir__) - File.rename(old_path, new_path) -end - -# Keep shakapacker.yml since we're using Shakapacker 8+ -# move("../react_on_rails/spec/dummy/config/shakapacker.yml", "../react_on_rails/spec/dummy/config/webpacker.yml") - -# Shakapacker - use version with async script loading support (8.2.0+) -gsub_file_content("../react_on_rails/Gemfile.development_dependencies", /gem "shakapacker", "[^"]*"/, 'gem "shakapacker", "8.2.0"') -gsub_file_content("../react_on_rails/spec/dummy/package.json", /"shakapacker": "[^"]*",/, '"shakapacker": "8.2.0",') - # The below packages don't work on the oldest supported Node version and aren't needed there anyway # Note: All dev dependencies remain in root package.json even after workspace migration gsub_file_content("../package.json", /"[^"]*eslint[^"]*": "[^"]*",?/, "") @@ -42,13 +54,7 @@ gsub_file_content("../package.json", %r{"@testing-library/[^"]*": "[^"]*",}, "") # Clean up any trailing commas before closing braces gsub_file_content("../package.json", /,(\s*})/, "\\1") -# Switch to minimum supported React version (React 18 since we removed PropTypes) -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("../packages/react-on-rails-pro/package.json", /"react": "[^"]*",/, '"react": "18.0.0",') -gsub_file_content("../packages/react-on-rails-pro/package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') -gsub_file_content("../react_on_rails/spec/dummy/package.json", /"react": "[^"]*",/, '"react": "18.0.0",') -gsub_file_content("../react_on_rails/spec/dummy/package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') +# Pro package: Skip RSC tests on React 18 since RSC requires React 19 gsub_file_content( "../packages/react-on-rails-pro/package.json", /"test:non-rsc": "(?:\\"|[^"])*",/, @@ -57,18 +63,3 @@ gsub_file_content( ) # test:rsc script now automatically detects React version and skips on React 18 # No override needed - the script checks React version and exits cleanly if < 19 -# Keep modern JSX transform for React 18+ -# gsub_file_content("../tsconfig.json", "react-jsx", "react") -# gsub_file_content("../spec/dummy/babel.config.js", "runtime: 'automatic'", "runtime: 'classic'") -# Keep modern ReScript configuration for React 18+ -# gsub_file_content("../spec/dummy/rescript.json", '"version": 4', '"version": 4, "mode": "classic"') -# Skip React 16 file replacements since we're using React 18+ -# Dir.glob(File.expand_path("../spec/dummy/**/app-react16/**/*.*", __dir__)).each do |file| -# move(file, file.gsub("-react16", "")) -# end - -# These replacements were incorrect - generateWebpackConfig() is the correct function from shakapacker -# gsub_file_content("../spec/dummy/config/webpack/commonWebpackConfig.js", /generateWebpackConfig(\(\))?/, -# "webpackConfig") -# -# gsub_file_content("../spec/dummy/config/webpack/webpack.config.js", /generateWebpackConfig(\(\))?/, "webpackConfig")