From 115e639fe93a11f4d633d4e5fd0802a19c3f1c14 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 24 Nov 2025 22:07:28 -1000 Subject: [PATCH 01/21] Update spec/dummy for React 19 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade react-redux from ^8.0.2 to ^9.2.0 for React 19 support - Upgrade redux from ^4.0.1 to ^5.0.1 (required by react-redux 9.x) - Upgrade redux-thunk from ^2.2.0 to ^3.1.0 (required by redux 5.x) - Replace react-helmet@^6.1.0 with @dr.pogodin/react-helmet@^3.0.4 (thread-safe React 19 compatible fork) Code changes: - Update redux-thunk imports to use named export: { thunk } - Update react-helmet SSR to use HelmetProvider with context prop - Remove @types/react-helmet (new package has built-in types) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- react_on_rails/spec/dummy/Gemfile.lock | 4 +-- .../app-react16/startup/ReduxApp.client.jsx | 4 +-- .../client/app/components/ReactHelmet.jsx | 5 +++- .../app/startup/ReactHelmetApp.server.jsx | 25 +++++++++++++++---- .../startup/ReactHelmetAppBroken.server.jsx | 13 +++++++--- .../client/app/startup/ReduxApp.client.jsx | 4 +-- .../client/app/startup/ReduxApp.server.jsx | 4 +-- .../client/app/stores/SharedReduxStore.jsx | 4 +-- react_on_rails/spec/dummy/package.json | 13 +++++----- 9 files changed, 49 insertions(+), 27 deletions(-) diff --git a/react_on_rails/spec/dummy/Gemfile.lock b/react_on_rails/spec/dummy/Gemfile.lock index eb919c4013..ce0a77dffb 100644 --- a/react_on_rails/spec/dummy/Gemfile.lock +++ b/react_on_rails/spec/dummy/Gemfile.lock @@ -345,7 +345,7 @@ GEM rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) semantic_range (3.1.0) - shakapacker (9.3.0) + shakapacker (9.4.0) activesupport (>= 5.2) package_json rack-proxy (>= 0.6.1) @@ -461,7 +461,7 @@ DEPENDENCIES sass-rails (~> 6.0) sdoc selenium-webdriver (= 4.9.0) - shakapacker (= 9.3.0) + shakapacker (= 9.4.0) spring (~> 4.0) sprockets (~> 4.0) sqlite3 (~> 1.6) 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.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.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..cfd8851e8d 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", + "@dr.pogodin/react-helmet": "^3.0.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", "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", From 1026398581293e7eb2ba3af7278a00ab068afb98 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 24 Nov 2025 22:30:32 -1000 Subject: [PATCH 02/21] Fix client-side HelmetProvider requirement for @dr.pogodin/react-helmet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HelmetProvider wrapper to client-side entry points for ReactHelmet components. The @dr.pogodin/react-helmet package requires HelmetProvider to wrap all Helmet components, on both server and client sides. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../dummy/client/app/startup/ReactHelmetApp.client.jsx | 8 +++++++- .../client/app/startup/ReactHelmetAppBroken.client.jsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) 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/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 From 05b231e0884ca5b98ecbb9e413f9c5820551d0af Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 26 Nov 2025 07:43:47 -1000 Subject: [PATCH 03/21] Refactor ReactHelmetApp.server.jsx and migrate Pro to @dr.pogodin/react-helmet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Fix ReactHelmetApp.server.jsx to wrap ReactHelmet component instead of duplicating component logic inline - Migrate react_on_rails_pro/spec/dummy to @dr.pogodin/react-helmet (React 19 compatible) - Update Pro shakapacker from 9.3.0 to 9.4.0 for consistency with open source - Add HelmetProvider wrappers to all client and server entry points in Pro - Use exact React 19.0.0 versions (consistent between open source and Pro) - Rename loadable-client.imports-loadable.js to .jsx (now contains JSX) The @dr.pogodin/react-helmet migration replaces Helmet.renderStatic() with the thread-safe HelmetProvider context pattern for SSR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/startup/ReactHelmetApp.server.jsx | 12 +- react_on_rails/spec/dummy/package.json | 4 +- .../code-splitting-loadable-components.md | 122 ++++++++++-------- .../pages/pro/loadable_component.html.erb | 2 +- .../client/app/components/Loadable/Header.jsx | 2 +- .../app/components/Loadable/pages/A.jsx | 2 +- .../app/components/Loadable/pages/B.jsx | 2 +- .../client/app/components/ReactHelmet.jsx | 2 +- .../loadable/loadable-client.imports-hmr.jsx | 8 +- ...s => loadable-client.imports-loadable.jsx} | 8 +- .../loadable/loadable-server.imports-hmr.jsx | 21 ++- .../loadable-server.imports-loadable.jsx | 23 +++- .../ReactHelmetApp.client.jsx | 6 +- .../ReactHelmetApp.server.jsx | 15 ++- react_on_rails_pro/spec/dummy/package.json | 4 +- 15 files changed, 141 insertions(+), 92 deletions(-) rename react_on_rails_pro/spec/dummy/client/app/loadable/{loadable-client.imports-loadable.js => loadable-client.imports-loadable.jsx} (59%) 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 6546457bca..5c9d10cb2b 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, HelmetProvider } from '@dr.pogodin/react-helmet'; -import HelloWorld from './HelloWorld'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; +import ReactHelmet from '../components/ReactHelmet'; /* * Export a function that takes the props and returns an object with { renderedHtml } @@ -22,13 +22,7 @@ export default (props, _railsContext) => { const componentHtml = renderToString( -
- - Custom page title - - Props: {JSON.stringify(props)} - -
+
, ); diff --git a/react_on_rails/spec/dummy/package.json b/react_on_rails/spec/dummy/package.json index cfd8851e8d..84c0436be3 100644 --- a/react_on_rails/spec/dummy/package.json +++ b/react_on_rails/spec/dummy/package.json @@ -19,8 +19,8 @@ "null-loader": "^4.0.0", "prop-types": "^15.7.2", "@dr.pogodin/react-helmet": "^3.0.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.0.0", + "react-dom": "19.0.0", "react-on-rails": "link:.yalc/react-on-rails", "react-redux": "^9.2.0", "react-router-dom": "^6.0.0", diff --git a/react_on_rails_pro/docs/code-splitting-loadable-components.md b/react_on_rails_pro/docs/code-splitting-loadable-components.md index bd06ea4a59..6adb0547ac 100644 --- a/react_on_rails_pro/docs/code-splitting-loadable-components.md +++ b/react_on_rails_pro/docs/code-splitting-loadable-components.md @@ -1,12 +1,14 @@ # Server-side rendering with code-splitting using Loadable/Components + by ShakaCode -*Last updated September 19, 2022* +_Last updated September 19, 2022_ ## Introduction + The [React library recommends](https://loadable-components.com/docs/getting-started/) the use of React.lazy for code splitting with dynamic imports except when using server-side rendering. In that case, as of February 2020, they recommend [Loadable Components](https://loadable-components.com) -for server-side rendering with dynamic imports. +for server-side rendering with dynamic imports. Note, in 2019 and prior, the code-splitting feature was implemented using `react-loadable`. The React team no longer recommends that library. The new way is far preferable. @@ -18,7 +20,8 @@ yarn add @loadable/babel-plugin @loadable/component @loadable/server @loadable/ ``` ### Summary -- [`@loadable/babel-plugin`](https://loadable-components.com/docs/getting-started/) - The plugin transforms your code to be ready for Server Side Rendering. + +- [`@loadable/babel-plugin`](https://loadable-components.com/docs/getting-started/) - The plugin transforms your code to be ready for Server Side Rendering. - `@loadable/component` - Main library for creating loadable components. - `@loadable/server` - Has functions for collecting chunks and provide style, script, link tags for the server. - `@loadable/webpack-plugin` - The plugin to create a stats file with all chunks, assets information. @@ -35,15 +38,16 @@ See example of server configuration differences in the loadable-components [exam for server-side rendering](https://github.com/gregberge/loadable-components/blob/master/examples/server-side-rendering/webpack.config.babel.js) You need to configure 3 things: -1. `target` - a. client-side: `web` - b. server-side: `node` + +1. `target` + a. client-side: `web` + b. server-side: `node` 2. `output.libraryTarget` - a. client-side: `undefined` - b. server-side: `commonjs2` -3. babel-loader options.caller = 'node' or 'web' -3. `plugins` - a. server-side: `new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })` + a. client-side: `undefined` + b. server-side: `commonjs2` +3. babel-loader options.caller = 'node' or 'web' +4. `plugins` + a. server-side: `new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })` ```js { @@ -58,14 +62,15 @@ You need to configure 3 things: Explanation: - `target: 'node'` is required to be able to run the server bundle with the dynamic import logic on nodejs. -If that is not done, webpack will add and invoke browser-specific functions to fetch the chunks into the bundle, which throws an error on server-rendering. + If that is not done, webpack will add and invoke browser-specific functions to fetch the chunks into the bundle, which throws an error on server-rendering. - `new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })` -The react_on_rails_pro node-renderer expects only one single server-bundle. In other words, we cannot and do not want to split the server bundle. + The react_on_rails_pro node-renderer expects only one single server-bundle. In other words, we cannot and do not want to split the server bundle. #### Client config For the client config we only need to add the plugin: + ```js { plugins: [ @@ -74,30 +79,33 @@ For the client config we only need to add the plugin: ] } ``` + This plugin collects all the information about entrypoints, chunks, and files, that have these chunks and creates a stats file during client bundle build. This stats file is used later to map rendered components to file assets. While you can use any filename, our documentation will use the default name. ### Babel Per [the docs](https://loadable-components.com/docs/babel-plugin/#transformation): + > The plugin transforms your code to be ready for Server Side Rendering Add this to `babel.config.js`: + ```js { "plugins": ["@loadable/babel-plugin"] } ``` -https://loadable-components.com/docs/babel-plugin/ +https://loadable-components.com/docs/babel-plugin/ ### Convert components into loadable components Instead of importing the component directly, use a dynamic import: ```js -import load from '@loadable/component' -const MyComponent = load(() => import('./MyComponent')) +import load from '@loadable/component'; +const MyComponent = load(() => import('./MyComponent')); ``` ### Resolving issue with ChunkLoadError @@ -118,22 +126,25 @@ const consoleDebug = (fn) => { console.debug(fn()); } }; -const retry = (fn, retryMessage = '', retriesLeft = 3, interval = 500) => new Promise((resolve, reject) => { - fn() - .then(resolve) - .catch(() => { - setTimeout(() => { - if (retriesLeft === 1) { - console.warn(`Maximum retries exceeded, retryMessage: ${retryMessage}. Reloading page...`); - window.location.reload(); - return; - } - // Passing on "reject" is the important part - consoleDebug(() => `Trying request, retryMessage: ${retryMessage}, retriesLeft: ${retriesLeft - 1}`); - retry(fn, retryMessage, retriesLeft - 1, interval).then(resolve, reject); - }, interval); - }); -}); +const retry = (fn, retryMessage = '', retriesLeft = 3, interval = 500) => + new Promise((resolve, reject) => { + fn() + .then(resolve) + .catch(() => { + setTimeout(() => { + if (retriesLeft === 1) { + console.warn(`Maximum retries exceeded, retryMessage: ${retryMessage}. Reloading page...`); + window.location.reload(); + return; + } + // Passing on "reject" is the important part + consoleDebug( + () => `Trying request, retryMessage: ${retryMessage}, retriesLeft: ${retriesLeft - 1}`, + ); + retry(fn, retryMessage, retriesLeft - 1, interval).then(resolve, reject); + }, interval); + }); + }); export default retry; ``` @@ -152,21 +163,21 @@ const HomePage = loadable(() => retry(() => import('./HomePage'))); In the client bundle, we need to wrap the `hydrateRoot` call into a `loadableReady` function. So, hydration will be fired only after all necessary chunks preloads. In this example below, -`ClientApp` is registering as `App`. +`ClientApp` is registering as `App`. ```js import React from 'react'; import ReactOnRails from 'react-on-rails'; -import { hydrateRoot } from 'react-dom/client' -import { loadableReady } from '@loadable/component' +import { hydrateRoot } from 'react-dom/client'; +import { loadableReady } from '@loadable/component'; import App from './App'; const ClientApp = (props, railsContext, domId) => { loadableReady(() => { - const root = document.getElementById(domId) + const root = document.getElementById(domId); hydrateRoot(root, ); - }) -} + }); +}; ReactOnRails.register({ App: ClientApp, @@ -175,20 +186,20 @@ ReactOnRails.register({ #### Server -The purpose of the server function is to collect all rendered chunks and pass them as script, link, -style tags to the Rails view. In this example below, `ServerApp` is registering as `App`. +The purpose of the server function is to collect all rendered chunks and pass them as script, link, +style tags to the Rails view. In this example below, `ServerApp` is registering as `App`. ```js import React from 'react'; import ReactOnRails from 'react-on-rails'; -import { ChunkExtractor } from '@loadable/server' -import App from './App' -import path from 'path' +import { ChunkExtractor } from '@loadable/server'; +import App from './App'; +import path from 'path'; const ServerApp = (props, railsContext) => { // This loadable-stats file was generated by `LoadablePlugin` in client webpack config. // You must configure the path to resolve per your setup. If you are copying the file to - // a remote server, the file should be a sibling of this file. + // a remote server, the file should be a sibling of this file. // __dirname is going to be the directory where the server-bundle.js exists // Note, React on Rails Pro automatically copies the loadable-stats.json to the same place as the // server-bundle.js. Thus, the __dirname of this code is where we can find loadable-stats.json. @@ -198,10 +209,10 @@ const ServerApp = (props, railsContext) => { // This object is used to search filenames by corresponding chunk names. // See https://loadable-components.com/docs/api-loadable-server/#chunkextractor // for the entryPoints, pass an array of all your entryPoints using dynamic imports - const extractor = new ChunkExtractor({ statsFile, entrypoints: ['client-bundle'] }) + const extractor = new ChunkExtractor({ statsFile, entrypoints: ['client-bundle'] }); // It creates the wrapper `ChunkExtractorManager` around `App` to collect chunk names of rendered components. - const jsx = extractor.collectChunks() + const jsx = extractor.collectChunks(); const componentHtml = renderToString(jsx); @@ -211,8 +222,8 @@ const ServerApp = (props, railsContext) => { // Returns all the files with rendered chunks for furture insert into rails view. linkTags: extractor.getLinkTags(), styleTags: extractor.getStyleTags(), - scriptTags: extractor.getScriptTags() - } + scriptTags: extractor.getScriptTags(), + }, }; }; @@ -224,6 +235,7 @@ ReactOnRails.register({ ## Configure react_on_rails_pro ### React on Rails Pro + You must set `config.assets_top_copy` so that the node-renderer will have access to the loadable-stats.json. ```ruby @@ -233,15 +245,16 @@ You must set `config.assets_top_copy` so that the node-renderer will have access Your server rendering code, per the above, will find this file like this: ```js - const statsFile = path.resolve(__dirname, 'loadable-stats.json'); -``` +const statsFile = path.resolve(__dirname, 'loadable-stats.json'); +``` Note, if `__dirname` is not working in your webpack build, that's because you didn't set `node: false` in your webpack configuration. That turns off the polyfills for things like `__dirname`. - ### Node Renderer + In your `node-renderer.js` file which runs node renderer, you need to specify `supportModules` options as follows: + ```js const path = require('path'); const env = process.env; @@ -261,7 +274,7 @@ reactOnRailsProNodeRenderer(config); ```erb <% res = react_component_hash("App", props: {}, prerender: true) %> <%= content_for :link_tags, res['linkTags'] %> -<%= content_for :style_tags, res['styleTags'] %> +<%= content_for :style_tags, res['styleTags'] %> <%= res['componentHtml'].html_safe %> @@ -269,6 +282,7 @@ reactOnRailsProNodeRenderer(config); ``` ## Making HMR Work + To make HMR work, it's best to disable loadable-components when using the Dev Server. Note: you will need access to our **private** React on Rails Pro repository to open the following links. @@ -277,9 +291,11 @@ Take a look at the code searches for ['imports-loadable'](https://github.com/sha The general concept is that we have a non-loadable, HMR-ready, file that substitutes for the loadable-enabled one, with the suffixes `imports-hmr.js` instead of `imports-loadable.js` ### Webpack configuration + Use the [NormalModuleReplacement plugin](https://webpack.js.org/plugins/normal-module-replacement-plugin/): [code](https://github.com/shakacode/react_on_rails_pro/blob/a361f4e163b9170f180ae07ee312fb9b4c719fc3/spec/dummy/config/webpack/environment.js#L81-L91) + ```js if (isWebpackDevServer) { environment.plugins.append( @@ -305,7 +321,7 @@ Note: you will need access to our **private** React on Rails Pro repository to o ### Client-Side Startup - [spec/dummy/client/app/loadable/loadable-client.imports-hmr.js](https://github.com/shakacode/react_on_rails_pro/blob/master/spec/dummy/client/app/loadable/loadable-client.imports-hmr.js) -- [spec/dummy/client/app/loadable/loadable-client.imports-loadable.js](https://github.com/shakacode/react_on_rails_pro/blob/master/spec/dummy/client/app/loadable/loadable-client.imports-loadable.js) +- [spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx](https://github.com/shakacode/react_on_rails_pro/blob/master/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx) ### Server-Side Startup diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/pro/loadable_component.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/pro/loadable_component.html.erb index d59d793283..c1f8366d9e 100644 --- a/react_on_rails_pro/spec/dummy/app/views/pages/pro/loadable_component.html.erb +++ b/react_on_rails_pro/spec/dummy/app/views/pages/pro/loadable_component.html.erb @@ -23,7 +23,7 @@ Client code

-<%= ApplicationHelper::include_code "loadable/loadable-client.imports-loadable.js" %>
+<%= ApplicationHelper::include_code "loadable/loadable-client.imports-loadable.jsx" %>
 

React Rails Server Rendering of Loadable Component

diff --git a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/Header.jsx b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/Header.jsx index 954dfed2b7..d1798aae96 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/Header.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/Header.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@dr.pogodin/react-helmet'; import ActiveLink from './ActiveLink'; const Header = () => ( diff --git a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/A.jsx b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/A.jsx index a35a4e3aa5..e5bf43bbaa 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/A.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/A.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@dr.pogodin/react-helmet'; export default () => ( <> diff --git a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/B.jsx b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/B.jsx index c31b3cd3d1..70ca174de6 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/B.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/Loadable/pages/B.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@dr.pogodin/react-helmet'; export default () => ( <> diff --git a/react_on_rails_pro/spec/dummy/client/app/components/ReactHelmet.jsx b/react_on_rails_pro/spec/dummy/client/app/components/ReactHelmet.jsx index e4b8c36589..7067cb7fd3 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/ReactHelmet.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/ReactHelmet.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from '@dr.pogodin/react-helmet'; import HelloWorld from '../ror-auto-load-components/HelloWorld'; import { consistentKeysReplacer } from '../utils/json'; diff --git a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-hmr.jsx b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-hmr.jsx index 226eb45b52..3479440963 100644 --- a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-hmr.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-hmr.jsx @@ -1,6 +1,12 @@ import React from 'react'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import Loadable from './LoadableApp'; -const WrappedLoadable = (props, railsContext) => () => ; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering +const WrappedLoadable = (props, railsContext) => () => ( + + + +); export default WrappedLoadable; diff --git a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.js b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx similarity index 59% rename from react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.js rename to react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx index da7b0046ec..b630825756 100644 --- a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.js +++ b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx @@ -2,13 +2,19 @@ import React from 'react'; import { hydrateRoot } from 'react-dom/client'; import { loadableReady } from '@loadable/component'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ClientApp from './LoadableApp'; const App = (props, railsContext, domNodeId) => { loadableReady(() => { const el = document.getElementById(domNodeId); - hydrateRoot(el, React.createElement(ClientApp, { ...props, path: railsContext.pathname })); + hydrateRoot( + el, + + {React.createElement(ClientApp, { ...props, path: railsContext.pathname })} + , + ); }); }; diff --git a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-hmr.jsx b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-hmr.jsx index 8cd4c5f6d6..bac3c1461a 100644 --- a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-hmr.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-hmr.jsx @@ -1,21 +1,28 @@ import React from 'react'; import { renderToString } from 'react-dom/server'; -import { Helmet } from 'react-helmet'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import App from './LoadableApp'; // Version of the consumer app to use without loadable components to enable HMR const hmrApp = (props, railsContext) => { - const componentHtml = renderToString(React.createElement(App, { ...props, path: railsContext.pathname })); - 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( + + {React.createElement(App, { ...props, path: railsContext.pathname })} + , + ); + const { helmet } = helmetContext; return { renderedHtml: { componentHtml, - link: helmet.link.toString(), - meta: helmet.meta.toString(), - style: helmet.style.toString(), - title: helmet.title.toString(), + link: helmet ? helmet.link.toString() : '', + meta: helmet ? helmet.meta.toString() : '', + style: helmet ? helmet.style.toString() : '', + title: helmet ? helmet.title.toString() : '', }, }; }; diff --git a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-loadable.jsx b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-loadable.jsx index 095d236ea4..19b50d0a58 100644 --- a/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-loadable.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/loadable/loadable-server.imports-loadable.jsx @@ -4,7 +4,7 @@ import path from 'path'; import React from 'react'; import { ChunkExtractor } from '@loadable/server'; import { renderToString } from 'react-dom/server'; -import { Helmet } from 'react-helmet'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import App from './LoadableApp'; @@ -14,19 +14,28 @@ const loadableApp = (props, railsContext) => { const statsFile = path.resolve(__dirname, 'loadable-stats.json'); const extractor = new ChunkExtractor({ entrypoints: ['client-bundle'], statsFile }); const { pathname } = railsContext; - const componentHtml = renderToString(extractor.collectChunks()); - 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( + extractor.collectChunks( + + + , + ), + ); + const { helmet } = helmetContext; return { renderedHtml: { componentHtml, - link: helmet.link.toString(), + link: helmet ? helmet.link.toString() : '', linkTags: extractor.getLinkTags(), styleTags: extractor.getStyleTags(), - meta: helmet.meta.toString(), + meta: helmet ? helmet.meta.toString() : '', scriptTags: extractor.getScriptTags(), - style: helmet.style.toString(), - title: helmet.title.toString(), + style: helmet ? helmet.style.toString() : '', + title: helmet ? helmet.title.toString() : '', }, }; }; diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.client.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.client.jsx index 4597810353..b24a1fa073 100644 --- a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.client.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.client.jsx @@ -2,10 +2,14 @@ // 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'; const stubbedResponse = { name: 'ReactOnRails', country: [], count: 0 }; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering export default (props, _railsContext) => () => ( - + + + ); diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.server.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.server.jsx index a60264919b..8b6e2106ee 100644 --- a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.server.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReactHelmetApp.server.jsx @@ -5,7 +5,7 @@ import 'cross-fetch/polyfill'; // 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 { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; /* @@ -28,12 +28,19 @@ export default async (props, _railsContext) => { console.error(`There was an error doing an API request during server rendering: ${error}`), ); - 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( + + + , + ); + const { helmet } = helmetContext; const promiseObject = { componentHtml, - title: helmet.title.toString(), + title: helmet ? helmet.title.toString() : '', }; return promiseObject; }; diff --git a/react_on_rails_pro/spec/dummy/package.json b/react_on_rails_pro/spec/dummy/package.json index bfe782fc95..bec8906f88 100644 --- a/react_on_rails_pro/spec/dummy/package.json +++ b/react_on_rails_pro/spec/dummy/package.json @@ -50,7 +50,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-error-boundary": "^4.1.2", - "react-helmet": "^6.0.0-beta.2", + "@dr.pogodin/react-helmet": "^3.0.4", "react-on-rails-pro": "link:.yalc/react-on-rails-pro", "react-on-rails-rsc": "^19.0.2", "react-proptypes": "^1.0.0", @@ -63,7 +63,7 @@ "sass": "^1.43.4", "sass-loader": "^12.3.0", "sass-resources-loader": "^2.0.0", - "shakapacker": "9.3.0", + "shakapacker": "9.4.0", "style-loader": "^3.3.1", "tailwindcss": "^3.2.7", "terser-webpack-plugin": "5", From 96a0dee624dba713f3eb4cb9ebc23d3450b77119 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 26 Nov 2025 08:35:24 -1000 Subject: [PATCH 04/21] Add guidance to commit and push by default when confident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index af69392b38..97bcdafcb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,19 @@ These requirements are non-negotiable. CI will fail if not followed. --- +## 🚀 COMMIT AND PUSH BY DEFAULT + +**When confident in your changes, commit and push without asking for permission.** + +- After completing a task successfully, commit and push immediately +- Run relevant tests locally first to verify changes work +- Don't wait for explicit user approval if you've tested and are confident +- Monitor CI after pushing and address any failures proactively + +This saves time and keeps the workflow moving efficiently. + +--- + ## 🚨 AVOIDING CI FAILURE CYCLES **CRITICAL**: Large-scale changes (directory structure, configs, workflows) require comprehensive local testing BEFORE pushing. From 9ff7069a35b3633f6c0d4c10970bdfdd22027fca Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 26 Nov 2025 08:36:03 -1000 Subject: [PATCH 05/21] Emphasize always monitoring CI after pushing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97bcdafcb3..3a02089dd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,8 @@ These requirements are non-negotiable. CI will fail if not followed. - After completing a task successfully, commit and push immediately - Run relevant tests locally first to verify changes work - Don't wait for explicit user approval if you've tested and are confident -- Monitor CI after pushing and address any failures proactively +- **ALWAYS monitor CI after pushing** - check status and address any failures proactively +- Keep monitoring until CI passes or issues are resolved This saves time and keeps the workflow moving efficiently. From e5051dfa8aad5d18f5d0340a2ad8b71b0f06e8d4 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 26 Nov 2025 08:59:52 -1000 Subject: [PATCH 06/21] Fix Shakapacker gem/npm version mismatch in Pro package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update shakapacker gem from 9.3.0 to 9.4.0 to match npm package version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Gemfile.development_dependencies | 2 +- react_on_rails_pro/spec/dummy/Gemfile.lock | 72 ++++++++++--------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/react_on_rails_pro/Gemfile.development_dependencies b/react_on_rails_pro/Gemfile.development_dependencies index 926c07ec3a..6519f1ced0 100644 --- a/react_on_rails_pro/Gemfile.development_dependencies +++ b/react_on_rails_pro/Gemfile.development_dependencies @@ -7,7 +7,7 @@ ruby '3.3.7' gem "react_on_rails", path: "../" -gem "shakapacker", "9.3.0" +gem "shakapacker", "9.4.0" gem "bootsnap", require: false gem "rails", "~> 7.1" gem "puma", "~> 6" diff --git a/react_on_rails_pro/spec/dummy/Gemfile.lock b/react_on_rails_pro/spec/dummy/Gemfile.lock index f865a0a92f..fa6aa67d8f 100644 --- a/react_on_rails_pro/spec/dummy/Gemfile.lock +++ b/react_on_rails_pro/spec/dummy/Gemfile.lock @@ -114,9 +114,9 @@ GEM io-event (~> 1.11) metrics (~> 0.12) traces (~> 0.18) - base64 (0.2.0) - benchmark (0.4.0) - bigdecimal (3.1.9) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.18.3) msgpack (~> 1.2) @@ -137,7 +137,7 @@ GEM childprocess (5.0.0) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.0) + connection_pool (2.5.5) console (1.34.2) fiber-annotation fiber-local (~> 1.1) @@ -154,12 +154,13 @@ GEM crass (1.0.6) csso-rails (1.0.0) execjs (>= 1) - date (3.4.1) + date (3.5.0) diff-lcs (1.5.1) docile (1.4.0) - drb (2.2.1) + drb (2.2.3) equivalent-xml (0.6.0) nokogiri (>= 1.4.3) + erb (6.0.0) erubi (1.13.1) execjs (2.9.1) fakefs (2.8.0) @@ -197,9 +198,9 @@ GEM http-2 (>= 1.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.0) + io-console (0.8.1) io-event (1.14.2) - irb (1.15.1) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -219,8 +220,8 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.6) - loofah (2.24.0) + logger (1.7.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -233,8 +234,8 @@ GEM method_source (1.1.0) metrics (0.15.0) mini_mime (1.1.5) - mini_portile2 (2.8.8) - minitest (5.25.4) + mini_portile2 (2.8.9) + minitest (5.26.2) mize (0.4.1) protocol (~> 2.0) msgpack (1.7.2) @@ -250,24 +251,24 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.8-aarch64-linux-gnu) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-aarch64-linux-musl) + nokogiri (1.18.10-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.8-arm-linux-gnu) + nokogiri (1.18.10-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-arm-linux-musl) + nokogiri (1.18.10-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.10-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) package_json (0.2.0) parallel (1.25.1) @@ -275,7 +276,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.6) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) prism-rails (1.5.0) @@ -292,17 +293,17 @@ GEM pry (>= 0.13.0) pry-theme (1.3.1) coderay (~> 1.1) - psych (5.2.3) + psych (5.2.6) date stringio public_suffix (6.0.0) puma (6.5.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.12) + rack (3.1.19) rack-proxy (0.7.7) rack - rack-session (2.1.0) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -323,7 +324,7 @@ GEM activesupport (= 7.2.2.1) bundler (>= 1.15.0) railties (= 7.2.2.1) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -339,20 +340,22 @@ GEM thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) + rake (13.3.1) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) rbs (3.9.5) logger - rdoc (6.12.0) + rdoc (6.16.0) + erb psych (>= 4.0.0) + tsort redis (5.4.0) redis-client (>= 0.22.0) redis-client (0.24.0) connection_pool regexp_parser (2.9.2) - reline (0.6.0) + reline (0.6.3) io-console (~> 0.5) rexml (3.3.9) rspec-core (3.13.0) @@ -422,7 +425,7 @@ GEM websocket (~> 1.0) semantic_range (3.1.0) sexp_processor (4.17.1) - shakapacker (9.3.0) + shakapacker (9.4.0) activesupport (>= 5.2) package_json rack-proxy (>= 0.6.1) @@ -452,18 +455,19 @@ GEM sqlite3 (1.7.3-x86-linux) sqlite3 (1.7.3-x86_64-darwin) sqlite3 (1.7.3-x86_64-linux) - stringio (3.1.2) + stringio (3.1.8) sync (0.5.0) term-ansicolor (1.10.2) mize tins (~> 1.0) - thor (1.3.2) + thor (1.4.0) tilt (2.4.0) timeout (0.4.3) tins (1.33.0) bigdecimal sync traces (0.18.2) + tsort (0.2.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -495,7 +499,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.36) - zeitwerk (2.7.1) + zeitwerk (2.7.3) PLATFORMS aarch64-linux @@ -556,7 +560,7 @@ DEPENDENCIES sass-rails scss_lint selenium-webdriver (= 4.9.0) - shakapacker (= 9.3.0) + shakapacker (= 9.4.0) spring spring-watcher-listen sprockets From d1e977937b4c35b4aa45681f731110c9b9beb4e9 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 26 Nov 2025 13:03:13 -1000 Subject: [PATCH 07/21] Fix minimum version CI failures for React 18 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Skip uncommitted changes check in CI environments - CI often makes temporary modifications (e.g., script/convert for minimum version testing) before running generators - Add CI=true check alongside existing COVERAGE=true check in git_utils.rb - Add test coverage for CI environment skip behavior 2. Use react-helmet-async for React 18 minimum version testing - @dr.pogodin/react-helmet only supports React 19+ - react-helmet-async supports React 16-18 and has the same HelmetProvider API - script/convert now swaps the package and updates imports for minimum testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../lib/react_on_rails/git_utils.rb | 4 +- .../spec/react_on_rails/git_utils_spec.rb | 44 +++++++++++++++++++ script/convert | 13 ++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/react_on_rails/lib/react_on_rails/git_utils.rb b/react_on_rails/lib/react_on_rails/git_utils.rb index 151d2aeedf..d0b2debc49 100644 --- a/react_on_rails/lib/react_on_rails/git_utils.rb +++ b/react_on_rails/lib/react_on_rails/git_utils.rb @@ -5,7 +5,9 @@ module ReactOnRails module GitUtils def self.uncommitted_changes?(message_handler, git_installed: true) - return false if ENV["COVERAGE"] == "true" + # Skip check in CI environments - CI often makes temporary modifications + # (e.g., script/convert for minimum version testing) before running generators + return false if ENV["CI"] == "true" || ENV["COVERAGE"] == "true" status = `git status --porcelain` return false if git_installed && status&.empty? diff --git a/react_on_rails/spec/react_on_rails/git_utils_spec.rb b/react_on_rails/spec/react_on_rails/git_utils_spec.rb index 86d70a5a0e..baeaf95981 100644 --- a/react_on_rails/spec/react_on_rails/git_utils_spec.rb +++ b/react_on_rails/spec/react_on_rails/git_utils_spec.rb @@ -8,6 +8,14 @@ module ReactOnRails context "with uncommitted git changes" do let(:message_handler) { instance_double("MessageHandler") } # rubocop:disable RSpec/VerifiedDoubleReference + around do |example| + # Temporarily unset CI env var to test actual uncommitted changes behavior + original_ci = ENV.fetch("CI", nil) + ENV.delete("CI") + example.run + ENV["CI"] = original_ci if original_ci + end + it "returns true" do allow(described_class).to receive(:`).with("git status --porcelain").and_return("M file/path") expect(message_handler).to receive(:add_error) @@ -22,9 +30,37 @@ module ReactOnRails end end + context "when CI environment variable is set" do + let(:message_handler) { instance_double("MessageHandler") } # rubocop:disable RSpec/VerifiedDoubleReference + + around do |example| + original_ci = ENV.fetch("CI", nil) + ENV["CI"] = "true" + example.run + ENV["CI"] = original_ci + ENV.delete("CI") unless original_ci + end + + it "returns false without checking git status" do + # Should not call git status at all + expect(described_class).not_to receive(:`) + expect(message_handler).not_to receive(:add_error) + + expect(described_class.uncommitted_changes?(message_handler, git_installed: true)).to be(false) + end + end + context "with clean git status" do let(:message_handler) { instance_double("MessageHandler") } # rubocop:disable RSpec/VerifiedDoubleReference + around do |example| + # Temporarily unset CI env var to test actual clean git behavior + original_ci = ENV.fetch("CI", nil) + ENV.delete("CI") + example.run + ENV["CI"] = original_ci if original_ci + end + it "returns false" do allow(described_class).to receive(:`).with("git status --porcelain").and_return("") expect(message_handler).not_to receive(:add_error) @@ -36,6 +72,14 @@ module ReactOnRails context "with git not installed" do let(:message_handler) { instance_double("MessageHandler") } # rubocop:disable RSpec/VerifiedDoubleReference + around do |example| + # Temporarily unset CI env var to test actual git not installed behavior + original_ci = ENV.fetch("CI", nil) + ENV.delete("CI") + example.run + ENV["CI"] = original_ci if original_ci + end + it "returns true" do allow(described_class).to receive(:`).with("git status --porcelain").and_return(nil) expect(message_handler).to receive(:add_error) diff --git a/script/convert b/script/convert index b99413323f..c3fcc39b01 100755 --- a/script/convert +++ b/script/convert @@ -47,6 +47,19 @@ 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("../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",') + +# Switch from @dr.pogodin/react-helmet (React 19+) to react-helmet-async (React 18 compatible) +# Both have the same HelmetProvider API, but different package names +gsub_file_content("../react_on_rails/spec/dummy/package.json", /"@dr\.pogodin\/react-helmet": "[^"]*",/, '"react-helmet-async": "^1.3.0",') +# Update import statements in all client files +Dir.glob(File.expand_path("../react_on_rails/spec/dummy/client/**/*.{js,jsx,ts,tsx}", __dir__)).each do |file| + content = File.binread(file) + if content.include?("@dr.pogodin/react-helmet") + content.gsub!(%r{from ['"]@dr\.pogodin/react-helmet['"]}, 'from "react-helmet-async"') + content.gsub!(%r{['"]@dr\.pogodin/react-helmet['"]}, '"react-helmet-async"') + File.binwrite(file, content) + end +end gsub_file_content( "../packages/react-on-rails-pro/package.json", /"test:non-rsc": "(?:\\"|[^"])*",/, From 224deecf13a43434837acb0c86994b2165cb29c2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 25 Nov 2025 14:20:54 -1000 Subject: [PATCH 08/21] Fix RSC test infrastructure: Convert jest.setup.js to ESM and re-enable tests (#2124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #2122 - Convert `jest.setup.js` from CommonJS `require()` to ESM imports for compatibility with ESM mode - Re-enable RSC tests with automatic React version detection - Remove unnecessary `script/convert` override since `package.json` now handles version checking automatically ## Changes ### `packages/react-on-rails-pro/tests/jest.setup.js` Moved `require()` statements to top-level ESM imports: - `import { TextEncoder, TextDecoder } from 'util'` - `import { Readable } from 'stream'` - `import { ReadableStream, ReadableStreamDefaultReader } from 'stream/web'` - `import { jest } from '@jest/globals'` ### `packages/react-on-rails-pro/package.json` Re-enabled `test:rsc` script with React version detection: - Tests run normally on React 19+ where RSC features are available - Tests skip gracefully on React 18 with informative message: `RSC tests skipped (requires React 19+, found 18.0.0)` ### `script/convert` Removed the override that disabled RSC tests since the version check is now built into `package.json`. ## Test plan - [x] Run `yarn test` in `packages/react-on-rails-pro` - passes with React 18 (RSC tests skipped) - [x] Verify RSC tests will run on React 19 in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit ## Release Notes * **Chores** * Updated RSC test infrastructure to conditionally run based on React version; tests now execute for React 19+ and skip for earlier versions. * Modernized Jest setup configuration to use ES module imports instead of dynamic requires, improving compatibility with modern JavaScript standards. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Claude --- .gitignore | 3 +++ packages/react-on-rails-pro/package.json | 2 +- .../scripts/check-react-version.cjs | 19 +++++++++++++++++++ .../react-on-rails-pro/tests/jest.setup.js | 12 +++++------- script/convert | 8 ++------ 5 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 packages/react-on-rails-pro/scripts/check-react-version.cjs diff --git a/.gitignore b/.gitignore index cdb3a55c28..2f2ceb077c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Root Gemfile.lock (delegates to react_on_rails/Gemfile, lock is there) +/Gemfile.lock + .bundle/ /.yardoc /_yardoc/ diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index e35c53f244..d6834545ac 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -9,7 +9,7 @@ "clean": "rm -rf ./lib", "test": "pnpm run test:non-rsc && pnpm run test:rsc", "test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(RSC|stream|registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"", - "test:rsc": "echo 'TODO: RSC tests disabled - jest.setup.js uses require() incompatible with ESM mode. Re-enable after converting setup to ESM.' && exit 0", + "test:rsc": "node scripts/check-react-version.cjs || NODE_CONDITIONS=react-server jest tests/*.rsc.test.*", "type-check": "tsc --noEmit --noErrorTruncation", "prepare": "[ -f lib/ReactOnRails.full.js ] || (rm -rf ./lib && tsc)", "prepublishOnly": "pnpm run build", diff --git a/packages/react-on-rails-pro/scripts/check-react-version.cjs b/packages/react-on-rails-pro/scripts/check-react-version.cjs new file mode 100644 index 0000000000..0a4ea5a12a --- /dev/null +++ b/packages/react-on-rails-pro/scripts/check-react-version.cjs @@ -0,0 +1,19 @@ +/** + * Check if React version supports RSC features (React 19+). + * Exits with code 0 if React < 19 (skip RSC tests). + * Exits with code 1 if React >= 19 (run RSC tests). + * + * This is a CommonJS file (.cjs) because it needs to use require() + * and the parent package has "type": "module". + */ +const v = require('react/package.json').version; + +const majorVersion = parseInt(v, 10); + +if (majorVersion < 19) { + console.log(`RSC tests skipped (requires React 19+, found ${v})`); + process.exit(0); +} + +// Exit with code 1 so the || chain continues to run Jest +process.exit(1); diff --git a/packages/react-on-rails-pro/tests/jest.setup.js b/packages/react-on-rails-pro/tests/jest.setup.js index 2758e3eb80..8e0487d636 100644 --- a/packages/react-on-rails-pro/tests/jest.setup.js +++ b/packages/react-on-rails-pro/tests/jest.setup.js @@ -1,3 +1,8 @@ +import { TextEncoder, TextDecoder } from 'util'; +import { Readable } from 'stream'; +import { ReadableStream, ReadableStreamDefaultReader } from 'stream/web'; +import { jest } from '@jest/globals'; + // If jsdom environment is set and TextEncoder is not defined, then define TextEncoder and TextDecoder // The current version of jsdom does not support TextEncoder and TextDecoder // The following code will tell us when jsdom supports TextEncoder and TextDecoder @@ -11,13 +16,6 @@ if (typeof window !== 'undefined' && typeof window.MessageChannel !== 'undefined } if (typeof window !== 'undefined') { - // eslint-disable-next-line global-require - const { TextEncoder, TextDecoder } = require('util'); - // eslint-disable-next-line global-require - const { Readable } = require('stream'); - // eslint-disable-next-line global-require - const { ReadableStream, ReadableStreamDefaultReader } = require('stream/web'); - // Mock the fetch function to return a ReadableStream instead of Node's Readable stream // This matches browser behavior where fetch responses have ReadableStream bodies // Node's fetch and polyfills like jest-fetch-mock return Node's Readable stream, diff --git a/script/convert b/script/convert index c3fcc39b01..ba447e6ef1 100755 --- a/script/convert +++ b/script/convert @@ -66,12 +66,8 @@ gsub_file_content( '"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(RSC|stream|' \ 'registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"",' ) -# Make test:rsc script do nothing -gsub_file_content( - "../packages/react-on-rails-pro/package.json", - /"test:rsc": "(?:\\"|[^"])*",/, - '"test:rsc": "exit 0",' -) +# 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'") From 64b363f844f28ac0db8358504d589cb0100c0413 Mon Sep 17 00:00:00 2001 From: Ihab Adham <71561048+ihabadham@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:22:08 +0200 Subject: [PATCH 09/21] Add client disconnect handling for concurrent component streaming (#2137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds error handling for client disconnects during streaming, which was missing after the architecture change in #2111. ## Background - **#2015** introduced concurrent component streaming with `Async::Semaphore` - **#2017** and **#2026** added client disconnect handling for that architecture (both closed, not merged) - **#2111** rewrote the streaming architecture using `Async::Barrier` + `Async::Variable` + `Async::LimitedQueue` - The error handling from #2017/#2026 was never adapted for the new architecture ## Problem When a client disconnects mid-stream (browser closed, network drop), `response.stream.write` raises `IOError` or `Errno::EPIPE`. Without handling: 1. The exception crashes the request 2. Producer tasks continue processing and enqueueing chunks unnecessarily, wasting CPU ## Changes ### 1. Writer error handling (`stream.rb`) - Catch `IOError`/`Errno::EPIPE` in writing task - Set `client_disconnected` flag and stop barrier to cancel producer tasks - Log disconnect for debugging (when `logging_on_server` enabled) ### 2. Producer early termination (`react_on_rails_pro_helper.rb`) - Add `stream.closed?` check before processing each chunk - Prevents deadlock when producer blocks on full queue after writer dies - Prevents wasted CPU when producer runs ahead of failed writer - Extract `process_stream_chunks` method for clarity ### 3. Configuration validation (`configuration.rb`) - Add setter validation for `concurrent_component_streaming_buffer_size` - Must be a positive integer ### 4. Tests - Add `client disconnect handling` describe block with IOError and EPIPE tests - Add buffer size validation tests - Add `closed?` stub to stream test setup ## Test Plan - [x] `bundle exec rspec spec/react_on_rails_pro/` - all 152 tests pass - [x] `bundle exec rubocop` - no offenses - [x] Verified tests fail on master, pass on this branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved streaming stability: better client-disconnect detection, logging, and shutdown to avoid wasted work while preserving previous chunk delivery semantics. * **Configuration** * Added a validated setting for concurrent streaming buffer size; only positive integers are accepted. * **Enhancements** * Centralized stream chunk processing for more consistent handling of first and subsequent chunks. * **Tests** * Added tests covering disconnect scenarios and buffer-size validation. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Claude --- .../app/helpers/react_on_rails_pro_helper.rb | 43 +++++++------ .../lib/react_on_rails_pro/concerns/stream.rb | 34 +++++++++- .../lib/react_on_rails_pro/configuration.rb | 30 ++++++--- .../helpers/react_on_rails_pro_helper_spec.rb | 64 ++++++++++++++++++- .../react_on_rails_pro/configuration_spec.rb | 37 +++++++++++ .../spec/react_on_rails_pro/stream_spec.rb | 58 +++++++++++++++++ 6 files changed, 234 insertions(+), 32 deletions(-) diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index 69c20af010..4c48941df8 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -313,25 +313,7 @@ def consumer_stream_async(on_complete:) # Start an async task on the barrier to stream all chunks @async_barrier.async do stream = yield - is_first = true - - stream.each_chunk do |chunk| - all_chunks << chunk if on_complete # Collect for callback - - if is_first - # Store first chunk in variable for synchronous access - first_chunk_var.value = chunk - is_first = false - else - # Enqueue remaining chunks to main output queue - @main_output_queue.enqueue(chunk) - end - end - - # Handle case where stream has no chunks - first_chunk_var.value = nil if is_first - - # Call callback with all chunks when streaming completes + process_stream_chunks(stream, first_chunk_var, all_chunks) on_complete&.call(all_chunks) end @@ -340,6 +322,29 @@ def consumer_stream_async(on_complete:) first_chunk_var.value end + def process_stream_chunks(stream, first_chunk_var, all_chunks) + is_first = true + + stream.each_chunk do |chunk| + # Check if client disconnected before processing chunk + break if response.stream.closed? + + all_chunks&.push(chunk) + + if is_first + # Store first chunk in variable for synchronous return + first_chunk_var.value = chunk + is_first = false + else + # Enqueue remaining chunks to main output queue + @main_output_queue.enqueue(chunk) + end + end + + # Handle case where stream has no chunks + first_chunk_var.value = nil if is_first + end + def internal_stream_react_component(component_name, options = {}) options = options.merge(render_mode: :html_streaming) result = internal_react_component(component_name, options) diff --git a/react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb b/react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb index 5735248828..78c6de721c 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb @@ -60,12 +60,29 @@ def stream_view_containing_react_components(template:, close_stream_at_end: true private + # Drains all streaming tasks concurrently using a producer-consumer pattern. + # + # Producer tasks: Created by consumer_stream_async in the helper, each streams + # chunks from the renderer and enqueues them to @main_output_queue. + # + # Consumer task: Single writer dequeues chunks and writes to response stream. + # + # Client disconnect handling: + # - If client disconnects (IOError/Errno::EPIPE), writer stops gracefully + # - Barrier is stopped to cancel all producer tasks, preventing wasted work + # - No exception propagates to the controller for client disconnects def drain_streams_concurrently(parent_task) + client_disconnected = false + writing_task = parent_task.async do # Drain all remaining chunks from the queue to the response stream while (chunk = @main_output_queue.dequeue) response.stream.write(chunk) end + rescue IOError, Errno::EPIPE => e + # Client disconnected - stop writing gracefully + client_disconnected = true + log_client_disconnect("writer", e) end # Wait for all component streaming tasks to complete @@ -76,9 +93,24 @@ def drain_streams_concurrently(parent_task) raise e end ensure - # Close the queue to signal end of streaming + # Close the queue first to unblock writing_task (it may be waiting on dequeue) @main_output_queue.close + + # Wait for writing_task to ensure client_disconnected flag is set + # before we check it (fixes race condition where ensure runs before + # writing_task's rescue block sets the flag) writing_task.wait + + # If client disconnected, stop all producer tasks to avoid wasted work + @async_barrier.stop if client_disconnected + end + + def log_client_disconnect(context, exception) + return unless ReactOnRails.configuration.logging_on_server + + Rails.logger.debug do + "[React on Rails Pro] Client disconnected during streaming (#{context}): #{exception.class}" + end end end end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb index b2b9e30cb8..3342943fd8 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb @@ -70,7 +70,26 @@ class Configuration # rubocop:disable Metrics/ClassLength :renderer_request_retry_limit, :throw_js_errors, :ssr_timeout, :profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support, :rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file, - :react_server_client_manifest_file, :concurrent_component_streaming_buffer_size + :react_server_client_manifest_file + + attr_reader :concurrent_component_streaming_buffer_size + + # Sets the buffer size for concurrent component streaming. + # + # This value controls how many chunks can be buffered in memory during + # concurrent streaming operations. When producers generate chunks faster + # than they can be written to the client, this buffer prevents unbounded + # memory growth by blocking producers when the buffer is full. + # + # @param value [Integer] A positive integer specifying the buffer size + # @raise [ReactOnRailsPro::Error] if value is not a positive integer + def concurrent_component_streaming_buffer_size=(value) + unless value.is_a?(Integer) && value.positive? + raise ReactOnRailsPro::Error, + "config.concurrent_component_streaming_buffer_size must be a positive integer" + end + @concurrent_component_streaming_buffer_size = value + end def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize renderer_use_fallback_exec_js: nil, prerender_caching: nil, @@ -118,7 +137,6 @@ def setup_config_values validate_remote_bundle_cache_adapter setup_renderer_password setup_assets_to_copy - validate_concurrent_component_streaming_buffer_size setup_execjs_profiler_if_needed check_react_on_rails_support_for_rsc end @@ -210,14 +228,6 @@ def validate_remote_bundle_cache_adapter end end - def validate_concurrent_component_streaming_buffer_size - return if concurrent_component_streaming_buffer_size.is_a?(Integer) && - concurrent_component_streaming_buffer_size.positive? - - raise ReactOnRailsPro::Error, - "config.concurrent_component_streaming_buffer_size must be a positive integer" - end - def setup_renderer_password return if renderer_password.present? diff --git a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index e8b90fd01f..6297c025b7 100644 --- a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -362,7 +362,9 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) end end - describe "#stream_react_component" do + describe "#stream_react_component" do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:mocked_rails_stream) { instance_double(ActionController::Live::Buffer) } + around do |example| # Wrap each test in Sync block to provide async context Sync do @@ -378,6 +380,14 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) end end + before do + # Mock response.stream.closed? for client disconnect detection + allow(mocked_rails_stream).to receive(:closed?).and_return(false) + mocked_rails_response = instance_double(ActionDispatch::Response) + allow(mocked_rails_response).to receive(:stream).and_return(mocked_rails_stream) + allow(self).to receive(:response).and_return(mocked_rails_response) + end + it "returns the component shell that exist in the initial chunk with the consoleReplayScript" do mock_request_and_response initial_result = stream_react_component(component_name, props: props, **component_options) @@ -452,6 +462,38 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) expect(collected_chunks[1]).to include(chunks_with_whitespaces[2][:html]) expect(collected_chunks[2]).to include(chunks_with_whitespaces[3][:html]) end + + it "stops processing chunks when client disconnects" do + many_chunks = Array.new(10) do |i| + { html: "
Chunk #{i}
", consoleReplayScript: "" } + end + mock_request_and_response(many_chunks) + + # Simulate client disconnect after first chunk + call_count = 0 + allow(mocked_rails_stream).to receive(:closed?) do + call_count += 1 + call_count > 1 # false for first call, true after + end + + # Start streaming - first chunk returned synchronously + initial_result = stream_react_component(component_name, props: props, **component_options) + expect(initial_result).to include("
Chunk 0
") + + # Wait for async task to complete + @async_barrier.wait + @main_output_queue.close + + # Collect chunks that were enqueued to output + collected_chunks = [] + while (chunk = @main_output_queue.dequeue) + collected_chunks << chunk + end + + # Should have stopped early - not all chunks processed + # The exact count depends on timing, but should be less than 9 (all remaining) + expect(collected_chunks.length).to be < 9 + end end describe "stream_view_containing_react_components" do # rubocop:disable RSpec/MultipleMemoizedHelpers @@ -476,6 +518,7 @@ def mock_request_and_response(mock_chunks = chunks, count: 1) written_chunks << chunk end allow(mocked_stream).to receive(:close) + allow(mocked_stream).to receive(:closed?).and_return(false) mocked_response = instance_double(ActionDispatch::Response) allow(mocked_response).to receive(:stream).and_return(mocked_stream) allow(self).to receive(:response).and_return(mocked_response) @@ -565,6 +608,7 @@ def execute_stream_view_containing_react_components written_chunks.clear allow(mocked_stream).to receive(:write) { |chunk| written_chunks << chunk } allow(mocked_stream).to receive(:close) + allow(mocked_stream).to receive(:closed?).and_return(false) mocked_response = instance_double(ActionDispatch::Response) allow(mocked_response).to receive(:stream).and_return(mocked_stream) allow(self).to receive(:response).and_return(mocked_response) @@ -709,7 +753,9 @@ def run_stream end end - describe "cached_stream_react_component integration with RandomValue", :caching do + describe "cached_stream_react_component integration with RandomValue", :caching do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:mocked_stream) { instance_double(ActionController::Live::Buffer) } + around do |example| original_prerender_caching = ReactOnRailsPro.configuration.prerender_caching ReactOnRailsPro.configuration.prerender_caching = true @@ -720,6 +766,13 @@ def run_stream Rails.cache.clear end + before do + allow(mocked_stream).to receive(:closed?).and_return(false) + mocked_response = instance_double(ActionDispatch::Response) + allow(mocked_response).to receive(:stream).and_return(mocked_stream) + allow(self).to receive(:response).and_return(mocked_response) + end + # we need this setup because we can't use the helper outside of stream_view_containing_react_components def render_cached_random_value(cache_key) # Streaming helpers require this context normally provided by stream_view_containing_react_components @@ -780,6 +833,7 @@ def render_cached_random_value(cache_key) { html: "
Test Content
", consoleReplayScript: "" } ] end + let(:mocked_stream) { instance_double(ActionController::Live::Buffer) } around do |example| Sync do @@ -790,6 +844,12 @@ def render_cached_random_value(cache_key) end before do + # Mock response.stream.closed? for client disconnect detection + allow(mocked_stream).to receive(:closed?).and_return(false) + mocked_response = instance_double(ActionDispatch::Response) + allow(mocked_response).to receive(:stream).and_return(mocked_stream) + allow(self).to receive(:response).and_return(mocked_response) + ReactOnRailsPro::Request.instance_variable_set(:@connection, nil) original_httpx_plugin = HTTPX.method(:plugin) allow(HTTPX).to receive(:plugin) do |*args| diff --git a/react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb index d2bbe6802c..138e422092 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb @@ -260,5 +260,42 @@ def self.fetch(*) expect(ReactOnRailsPro.configuration.react_server_client_manifest_file).to eq("server-client-manifest.json") end end + + describe ".concurrent_component_streaming_buffer_size" do + it "accepts positive integers" do + ReactOnRailsPro.configure do |config| + config.concurrent_component_streaming_buffer_size = 128 + end + + expect(ReactOnRailsPro.configuration.concurrent_component_streaming_buffer_size).to eq(128) + end + + it "raises error for non-positive integers" do + expect do + ReactOnRailsPro.configure do |config| + config.concurrent_component_streaming_buffer_size = 0 + end + end.to raise_error(ReactOnRailsPro::Error, + /must be a positive integer/) + end + + it "raises error for negative integers" do + expect do + ReactOnRailsPro.configure do |config| + config.concurrent_component_streaming_buffer_size = -1 + end + end.to raise_error(ReactOnRailsPro::Error, + /must be a positive integer/) + end + + it "raises error for non-integers" do + expect do + ReactOnRailsPro.configure do |config| + config.concurrent_component_streaming_buffer_size = "64" + end + end.to raise_error(ReactOnRailsPro::Error, + /must be a positive integer/) + end + end end end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb index 8ee1eed235..3b3ad5921a 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb @@ -392,6 +392,7 @@ def setup_stream_test(component_count: 2) allow(mocked_response).to receive(:stream).and_return(mocked_stream) allow(mocked_stream).to receive(:write) allow(mocked_stream).to receive(:close) + allow(mocked_stream).to receive(:closed?).and_return(false) allow(controller).to receive(:response).and_return(mocked_response) [component_queues, controller, mocked_stream] @@ -489,5 +490,62 @@ def setup_stream_test(component_count: 2) gaps = write_timestamps.each_cons(2).map { |a, b| b - a } expect(gaps.all? { |gap| gap >= 0.04 }).to be true end + + describe "client disconnect handling" do + it "stops writing on IOError" do + queues, controller, stream = setup_stream_test(component_count: 1) + + written_chunks = [] + write_count = 0 + + allow(stream).to receive(:write) do |chunk| + write_count += 1 + raise IOError, "client disconnected" if write_count == 3 + + written_chunks << chunk + end + + run_stream(controller) do |_parent| + queues[0].enqueue("Chunk1") + sleep 0.05 + queues[0].enqueue("Chunk2") + sleep 0.05 + queues[0].enqueue("Chunk3") + sleep 0.05 + queues[0].enqueue("Chunk4") + queues[0].close + sleep 0.1 + end + + # Write 1: TEMPLATE, Write 2: Chunk1, Write 3: Chunk2 (raises IOError) + expect(written_chunks).to eq(%w[TEMPLATE Chunk1]) + end + + it "stops writing on Errno::EPIPE" do + queues, controller, stream = setup_stream_test(component_count: 1) + + written_chunks = [] + write_count = 0 + + allow(stream).to receive(:write) do |chunk| + write_count += 1 + raise Errno::EPIPE, "broken pipe" if write_count == 3 + + written_chunks << chunk + end + + run_stream(controller) do |_parent| + queues[0].enqueue("Chunk1") + sleep 0.05 + queues[0].enqueue("Chunk2") + sleep 0.05 + queues[0].enqueue("Chunk3") + queues[0].close + sleep 0.1 + end + + expect(written_chunks).to eq(%w[TEMPLATE Chunk1]) + end + end end end From 0c4449a1202ce407a532931f03ab03d1565824c2 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Fri, 28 Nov 2025 12:42:56 +0200 Subject: [PATCH 10/21] Fix bin/dev script at the pro dummy app (#2140) --- react_on_rails_pro/spec/dummy/bin/dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react_on_rails_pro/spec/dummy/bin/dev b/react_on_rails_pro/spec/dummy/bin/dev index 9fb5cf7573..309cec3b54 100755 --- a/react_on_rails_pro/spec/dummy/bin/dev +++ b/react_on_rails_pro/spec/dummy/bin/dev @@ -2,4 +2,4 @@ # frozen_string_literal: true # This script calls the base dev script with a fixed route for the dummy app -exec File.join(__dir__, "../../../../lib/generators/react_on_rails/templates/base/base/bin/dev"), "--route=/", *ARGV +exec File.join(__dir__, "../../../../react_on_rails/lib/generators/react_on_rails/templates/base/base/bin/dev"), "--route=/", *ARGV From 88b7cbc69e1d68c262db43a7a2b4f128417eb874 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Fri, 28 Nov 2025 13:20:50 +0200 Subject: [PATCH 11/21] Add async_react_component and cached_async_react_component helpers (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds concurrent React component rendering support for React on Rails Pro. Closes #2138. - **`async_react_component`**: Returns `AsyncValue` immediately, renders components concurrently - **`cached_async_react_component`**: Async rendering with fragment caching support (same options as `cached_react_component`) - **`AsyncRendering` concern**: Controller mixin with `enable_async_react_rendering` class method ## Motivation Currently, multiple `react_component` calls in a Rails view run sequentially. Each call makes an HTTP request to the Node renderer, causing delays proportional to the number of components. This PR enables concurrent execution using the `async` gem (already used in the codebase for streaming). ## Usage ### Controller Setup ```ruby class ProductsController < ApplicationController include ReactOnRailsPro::AsyncRendering enable_async_react_rendering only: [:show, :index] end ``` ### View Usage ```erb <%# Start async renders (non-blocking) %> <% header = async_react_component("Header", props: @header_props) %> <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %> <% content = async_react_component("Content", props: @content_props) %> <%# Access values (blocks until ready) %> <%= header.value %> <%= sidebar.value %> <%= content.value %> ``` ### With Caching ```erb <% card = cached_async_react_component("ProductCard", cache_key: @product) { @product.to_props } %> <%= card.value %> ``` ## Implementation Details - Uses `Async::Barrier` for coordinating concurrent tasks within a request - Cache lookup is synchronous - cache hits return `ImmediateAsyncValue` (no async overhead) - Cache misses trigger async render and write to cache on completion - Errors propagate naturally via `task.wait` - Supports all `cached_react_component` options: `:cache_key`, `:cache_options`, `:if`, `:unless` ## Test plan - [x] Unit tests for `AsyncValue` class - [x] Unit tests for `ImmediateAsyncValue` class - [x] Integration tests for `async_react_component` - [x] Integration tests for `cached_async_react_component` - [x] Concurrent execution verification test - [x] Error propagation tests - [x] Cache hit/miss behavior tests - [x] Conditional caching (`:if`/`:unless`) tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Adds asynchronous server-side rendering for React components with barrier-backed concurrency and optional caching; cache hits return synchronously, misses queue async renders. * Controller opt-in to enable async rendering for selected actions. * Demo page and sample delayed components to illustrate concurrent rendering and timing. * **Tests** * Extensive tests covering async rendering, caching behavior, error propagation, and async value semantics. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Claude --- react_on_rails_pro/CHANGELOG.md | 1 + .../app/helpers/react_on_rails_pro_helper.rb | 123 ++++++++++++ react_on_rails_pro/lib/react_on_rails_pro.rb | 3 + .../lib/react_on_rails_pro/async_value.rb | 35 ++++ .../concerns/async_rendering.rb | 68 +++++++ .../immediate_async_value.rb | 27 +++ .../sig/react_on_rails_pro/async_value.rbs | 15 ++ .../concerns/async_rendering.rbs | 15 ++ .../immediate_async_value.rbs | 15 ++ .../dummy/app/controllers/pages_controller.rb | 9 + .../pages/pro/async_components_demo.html.erb | 34 ++++ .../DelayedComponent.client.jsx | 12 ++ .../DelayedComponent.server.jsx | 23 +++ .../spec/dummy/config/routes.rb | 1 + .../helpers/react_on_rails_pro_helper_spec.rb | 176 ++++++++++++++++++ .../react_on_rails_pro/async_value_spec.rb | 92 +++++++++ .../immediate_async_value_spec.rb | 46 +++++ 17 files changed, 695 insertions(+) create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/async_value.rb create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs create mode 100644 react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb create mode 100644 react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx create mode 100644 react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb diff --git a/react_on_rails_pro/CHANGELOG.md b/react_on_rails_pro/CHANGELOG.md index d12b865393..965e77c7dd 100644 --- a/react_on_rails_pro/CHANGELOG.md +++ b/react_on_rails_pro/CHANGELOG.md @@ -25,6 +25,7 @@ _Add changes in master not yet tagged._ ### Added +- **Async React Component Rendering**: Added `async_react_component` and `cached_async_react_component` helpers for concurrent component rendering. Multiple components now execute HTTP requests to the Node renderer in parallel instead of sequentially, significantly reducing latency when rendering multiple components in a view. Requires `ReactOnRailsPro::AsyncRendering` concern in controller. [PR 2139](https://github.com/shakacode/react_on_rails/pull/2139) by [AbanoubGhadban](https://github.com/AbanoubGhadban). - Added `config.concurrent_component_streaming_buffer_size` configuration option to control the memory buffer size for concurrent component streaming (defaults to 64). This allows fine-tuning of memory usage vs. performance for streaming applications. - Added `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components. - **License Validation System**: Implemented comprehensive JWT-based license validation with offline verification using RSA-256 signatures. License validation occurs at startup in both Ruby and Node.js environments. Supports required fields (`sub`, `iat`, `exp`) and optional fields (`plan`, `organization`, `iss`). FREE evaluation licenses are available for 3 months at [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro). [PR #1857](https://github.com/shakacode/react_on_rails/pull/1857) by [AbanoubGhadban](https://github.com/AbanoubGhadban). diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index 4c48941df8..9a9adf6b27 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -217,6 +217,63 @@ def cached_stream_react_component(component_name, raw_options = {}, &block) end end + # Renders a React component asynchronously, returning an AsyncValue immediately. + # Multiple async_react_component calls will execute their HTTP rendering requests + # concurrently instead of sequentially. + # + # Requires the controller to include ReactOnRailsPro::AsyncRendering and call + # enable_async_react_rendering. + # + # @param component_name [String] Name of your registered component + # @param options [Hash] Same options as react_component + # @return [ReactOnRailsPro::AsyncValue] Call .value to get the rendered HTML + # + # @example + # <% header = async_react_component("Header", props: @header_props) %> + # <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %> + # <%= header.value %> + # <%= sidebar.value %> + # + def async_react_component(component_name, options = {}) + unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier + raise ReactOnRailsPro::Error, + "async_react_component requires AsyncRendering concern. " \ + "Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering." + end + + task = @react_on_rails_async_barrier.async do + react_component(component_name, options) + end + + ReactOnRailsPro::AsyncValue.new(task: task) + end + + # Renders a React component asynchronously with caching support. + # Cache lookup is synchronous - cache hits return immediately without async. + # Cache misses trigger async render and cache the result on completion. + # + # All the same options as cached_react_component apply: + # 1. You must pass the props as a block (evaluated only on cache miss) + # 2. Provide the cache_key option + # 3. Optionally provide :cache_options for Rails.cache (expires_in, etc.) + # 4. Provide :if or :unless for conditional caching + # + # @param component_name [String] Name of your registered component + # @param options [Hash] Options including cache_key and cache_options + # @yield Block that returns props (evaluated only on cache miss) + # @return [ReactOnRailsPro::AsyncValue, ReactOnRailsPro::ImmediateAsyncValue] + # + # @example + # <% card = cached_async_react_component("ProductCard", cache_key: @product) { @product.to_props } %> + # <%= card.value %> + # + def cached_async_react_component(component_name, raw_options = {}, &block) + ReactOnRailsPro::Utils.with_trace(component_name) do + check_caching_options!(raw_options, block) + fetch_async_react_component(component_name, raw_options, &block) + end + end + if defined?(ScoutApm) include ScoutApm::Tracer instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component" @@ -298,6 +355,72 @@ def check_caching_options!(raw_options, block) raise ReactOnRailsPro::Error, "Option 'cache_key' is required for React on Rails caching" end + # Async version of fetch_react_component. Handles cache lookup synchronously, + # returns ImmediateAsyncValue on hit, AsyncValue on miss. + def fetch_async_react_component(component_name, raw_options, &block) + unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier + raise ReactOnRailsPro::Error, + "cached_async_react_component requires AsyncRendering concern. " \ + "Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering." + end + + # Check conditional caching (:if / :unless options) + unless ReactOnRailsPro::Cache.use_cache?(raw_options) + return render_async_react_component_uncached(component_name, raw_options, &block) + end + + cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, raw_options) + cache_options = raw_options[:cache_options] || {} + Rails.logger.debug { "React on Rails Pro async cache_key is #{cache_key.inspect}" } + + # Synchronous cache lookup + cached_result = Rails.cache.read(cache_key, cache_options) + if cached_result + Rails.logger.debug { "React on Rails Pro async cache HIT for #{cache_key.inspect}" } + render_options = ReactOnRails::ReactComponent::RenderOptions.new( + react_component_name: component_name, + options: raw_options + ) + load_pack_for_generated_component(component_name, render_options) + return ReactOnRailsPro::ImmediateAsyncValue.new(cached_result) + end + + Rails.logger.debug { "React on Rails Pro async cache MISS for #{cache_key.inspect}" } + render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block) + end + + # Renders async without caching (when :if/:unless conditions disable cache) + def render_async_react_component_uncached(component_name, raw_options, &block) + options = prepare_async_render_options(raw_options, &block) + + task = @react_on_rails_async_barrier.async do + react_component(component_name, options) + end + + ReactOnRailsPro::AsyncValue.new(task: task) + end + + # Renders async and writes to cache on completion + def render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block) + options = prepare_async_render_options(raw_options, &block) + + task = @react_on_rails_async_barrier.async do + result = react_component(component_name, options) + Rails.cache.write(cache_key, result, cache_options) + result + end + + ReactOnRailsPro::AsyncValue.new(task: task) + end + + def prepare_async_render_options(raw_options) + raw_options.merge( + props: yield, + skip_prerender_cache: true, + auto_load_bundle: ReactOnRails.configuration.auto_load_bundle || raw_options[:auto_load_bundle] + ) + end + def consumer_stream_async(on_complete:) require "async/variable" diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 5dbe2dafae..b7695b028d 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -20,4 +20,7 @@ require "react_on_rails_pro/prepare_node_renderer_bundles" require "react_on_rails_pro/concerns/stream" require "react_on_rails_pro/concerns/rsc_payload_renderer" +require "react_on_rails_pro/concerns/async_rendering" +require "react_on_rails_pro/async_value" +require "react_on_rails_pro/immediate_async_value" require "react_on_rails_pro/routes" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb new file mode 100644 index 0000000000..2395b17749 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # AsyncValue wraps an Async task to provide a simple interface for + # retrieving the result of an async react_component render. + # + # @example + # async_value = async_react_component("MyComponent", props: { name: "World" }) + # # ... do other work ... + # html = async_value.value # blocks until result is ready + # + class AsyncValue + def initialize(task:) + @task = task + end + + # Blocks until result is ready, returns HTML string. + # If the async task raised an exception, it will be re-raised here. + def value + @task.wait + end + + def resolved? + @task.finished? + end + + def to_s + value.to_s + end + + def html_safe + value.html_safe + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb new file mode 100644 index 0000000000..ee5de8649f --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # AsyncRendering enables concurrent rendering of multiple React components. + # When enabled, async_react_component calls will execute their HTTP requests + # in parallel instead of sequentially. + # + # @example Enable for all actions + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering + # end + # + # @example Enable for specific actions only + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering only: [:show, :index] + # end + # + # @example Enable for all except specific actions + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering except: [:create, :update] + # end + # + module AsyncRendering + extend ActiveSupport::Concern + + class_methods do + # Enables async React component rendering for controller actions. + # Accepts standard Rails filter options like :only and :except. + # + # @param options [Hash] Options passed to around_action (e.g., only:, except:) + def enable_async_react_rendering(**options) + around_action :wrap_in_async_react_context, **options + end + end + + private + + def wrap_in_async_react_context + require "async" + require "async/barrier" + + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + yield + check_for_unresolved_async_components + ensure + @react_on_rails_async_barrier&.stop + @react_on_rails_async_barrier = nil + end + end + + def check_for_unresolved_async_components + return if @react_on_rails_async_barrier.nil? + + pending_tasks = @react_on_rails_async_barrier.size + return if pending_tasks.zero? + + Rails.logger.error( + "[React on Rails Pro] #{pending_tasks} async component(s) were started but never resolved. " \ + "Make sure to call .value on all AsyncValue objects returned by async_react_component " \ + "or cached_async_react_component. Unresolved tasks will be stopped." + ) + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb new file mode 100644 index 0000000000..1282b105bf --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # ImmediateAsyncValue is returned when a cached_async_react_component call + # has a cache hit. It provides the same interface as AsyncValue but returns + # the cached value immediately without any async operations. + # + class ImmediateAsyncValue + def initialize(value) + @value = value + end + + attr_reader :value + + def resolved? + true + end + + def to_s + @value.to_s + end + + def html_safe + @value.html_safe + end + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs new file mode 100644 index 0000000000..d78583c1f7 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + class AsyncValue + @task: untyped + + def initialize: (task: untyped) -> void + + def value: () -> untyped + + def resolved?: () -> bool + + def to_s: () -> String + + def html_safe: () -> untyped + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs new file mode 100644 index 0000000000..bb64e7df43 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + module AsyncRendering + module ClassMethods + def enable_async_react_rendering: (**untyped options) -> void + end + + @react_on_rails_async_barrier: untyped + + private + + def wrap_in_async_react_context: () { () -> untyped } -> untyped + + def check_for_unresolved_async_components: () -> void + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs new file mode 100644 index 0000000000..d8ae3b4ca4 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + class ImmediateAsyncValue + attr_reader value: untyped + + @value: untyped + + def initialize: (untyped value) -> void + + def resolved?: () -> bool + + def to_s: () -> String + + def html_safe: () -> untyped + end +end diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb index 7a33950f91..740d7f0dd9 100644 --- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb @@ -3,6 +3,9 @@ class PagesController < ApplicationController # rubocop:disable Metrics/ClassLength include ReactOnRailsPro::RSCPayloadRenderer include RscPostsPageOverRedisHelper + include ReactOnRailsPro::AsyncRendering + + enable_async_react_rendering only: [:async_components_demo] XSS_PAYLOAD = { "" => '' }.freeze PROPS_NAME = "Mr. Server Side Rendering" @@ -157,6 +160,12 @@ def console_logs_in_async_server render "/pages/pro/console_logs_in_async_server" end + # Demo page showing 10 async components rendering concurrently + # Each component delays 1 second - sequential would take ~10s, concurrent takes ~1s + def async_components_demo + render "/pages/pro/async_components_demo" + end + # See files in spec/dummy/app/views/pages helper_method :calc_slow_app_props_server_render diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb new file mode 100644 index 0000000000..17f1355a43 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb @@ -0,0 +1,34 @@ +

Async React Components Demo

+

+ This page renders 10 React components, each with a 1-second delay. +
+ Sequential rendering: ~10 seconds +
+ Concurrent rendering (async_react_component): ~1 second +

+ +<% start_time = Time.now %> + +<% + # Start all 10 async renders immediately (non-blocking) + components = 10.times.map do |i| + async_react_component( + "DelayedComponent", + props: { index: i + 1, delayMs: 1000 }, + prerender: true + ) + end +%> + +
+ <% components.each do |component| %> + <%= component.value %> + <% end %> +
+ +<% elapsed = Time.now - start_time %> +

+ Total render time: <%= (elapsed * 1000).round %>ms +
+ If this is close to 1 second instead of 10 seconds, concurrent rendering is working! +

diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx new file mode 100644 index 0000000000..ae1d88bc41 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; + +// Client-side version of DelayedComponent (no delay needed on client) +const DelayedComponent = ({ index, delayMs = 1000 }) => ( +
+ Component {index} - Rendered after {delayMs}ms delay +
+); + +export default DelayedComponent; diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx new file mode 100644 index 0000000000..5dff0210b6 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx @@ -0,0 +1,23 @@ +'use client'; + +import React from 'react'; + +// Component that simulates a slow render by delaying for 1 second +// Used to demonstrate concurrent rendering with async_react_component +const DelayedComponent = ({ index, delayMs = 1000 }) => ( +
+ Component {index} - Rendered after {delayMs}ms delay +
+); + +// Async render function that delays for specified time before returning +export default async (props, _railsContext) => { + const { delayMs = 1000 } = props; + + // Simulate slow server-side data fetching + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + + return () => ; +}; diff --git a/react_on_rails_pro/spec/dummy/config/routes.rb b/react_on_rails_pro/spec/dummy/config/routes.rb index 1d2f2b4e0d..3fc717ba79 100644 --- a/react_on_rails_pro/spec/dummy/config/routes.rb +++ b/react_on_rails_pro/spec/dummy/config/routes.rb @@ -38,6 +38,7 @@ get "server_router_client_render/(*all)" => "pages#server_router_client_render", as: :server_router_client_render get "async_render_function_returns_string" => "pages#async_render_function_returns_string" get "async_render_function_returns_component" => "pages#async_render_function_returns_component" + get "async_components_demo" => "pages#async_components_demo", as: :async_components_demo rsc_payload_route controller: "pages" # routes copied over from react on rails diff --git a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index 6297c025b7..404961c03b 100644 --- a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -876,5 +876,181 @@ def render_cached_random_value(cache_key) expect(comment_count).to eq(1) end end + + describe "#async_react_component", :requires_webpack_assets do + context "without async context" do + it "raises an error when called outside async context" do + expect do + async_react_component("App", props: { a: 1 }) + end.to raise_error(ReactOnRailsPro::Error, /AsyncRendering concern/) + end + end + + context "with async context" do + around do |example| + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + example.run + ensure + @react_on_rails_async_barrier = nil + end + end + + it "returns an AsyncValue" do + result = async_react_component("App", props: { a: 1 }) + expect(result).to be_a(ReactOnRailsPro::AsyncValue) + end + + it "renders the component when value is accessed" do + async_value = async_react_component("App", props: { a: 1, b: 2 }) + html = async_value.value + + expect(html).to include('id="App-react-component') + end + + it "executes multiple components concurrently" do + call_count = 0 + max_concurrent = 0 + mutex = Mutex.new + + allow(self).to receive(:react_component) do |_name, _opts| + mutex.synchronize do + call_count += 1 + max_concurrent = [max_concurrent, call_count].max + end + + # Yield to other fibers to allow concurrent execution + Async::Task.current.yield + + mutex.synchronize { call_count -= 1 } + "
rendered
" + end + + value1 = async_react_component("App", props: { a: 1 }) + value2 = async_react_component("App", props: { b: 2 }) + + value1.value + value2.value + + # If concurrent, both calls should have been active at the same time + expect(max_concurrent).to eq(2) + expect(call_count).to eq(0) + end + + it "re-raises exceptions from react_component" do + allow(self).to receive(:react_component).and_raise(StandardError, "Render error") + + async_value = async_react_component("BadComponent", props: {}) + + expect { async_value.value }.to raise_error(StandardError, "Render error") + end + end + end + + describe "#cached_async_react_component", :caching, :requires_webpack_assets do + context "without async context" do + it "raises an error when called outside async context" do + expect do + cached_async_react_component("App", cache_key: "test") { { a: 1 } } + end.to raise_error(ReactOnRailsPro::Error, /AsyncRendering concern/) + end + end + + context "with async context" do + around do |example| + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + example.run + ensure + @react_on_rails_async_barrier = nil + end + end + + it "returns an AsyncValue on cache miss" do + result = cached_async_react_component("App", cache_key: "async-test-miss") { { a: 1 } } + expect(result).to be_a(ReactOnRailsPro::AsyncValue) + end + + it "returns an ImmediateAsyncValue on cache hit" do + # First call - cache miss + first_result = cached_async_react_component("App", cache_key: "async-test-hit") { { a: 1 } } + first_result.value # Wait for render and cache write + + # Second call - cache hit + second_result = cached_async_react_component("App", cache_key: "async-test-hit") { { a: 1 } } + expect(second_result).to be_a(ReactOnRailsPro::ImmediateAsyncValue) + end + + it "caches the rendered component" do + cache_key = "async-cache-test-#{SecureRandom.hex(4)}" + + # First render + first_value = cached_async_react_component("RandomValue", cache_key: cache_key) { { a: 1 } } + first_html = first_value.value + + # Second render should return cached content + second_value = cached_async_react_component("RandomValue", cache_key: cache_key) { { a: 1 } } + second_html = second_value.value + + expect(second_html).to eq(first_html) + end + + it "doesn't call the block on cache hit" do + cache_key = "async-block-test-#{SecureRandom.hex(4)}" + + # Prime the cache + first_value = cached_async_react_component("App", cache_key: cache_key) { { a: 1 } } + first_value.value + + # Second call should not yield + expect do |block| + cached_async_react_component("App", cache_key: cache_key, &block) + end.not_to yield_control + end + + it "respects :if option for conditional caching" do + cache_key = "async-if-test-#{SecureRandom.hex(4)}" + + # With if: false, should not cache + first_value = cached_async_react_component("RandomValue", cache_key: cache_key, if: false) { { a: 1 } } + first_html = first_value.value + + second_value = cached_async_react_component("RandomValue", cache_key: cache_key, if: false) { { a: 1 } } + second_html = second_value.value + + # Both should be AsyncValue (not ImmediateAsyncValue) since caching is disabled + expect(first_value).to be_a(ReactOnRailsPro::AsyncValue) + expect(second_value).to be_a(ReactOnRailsPro::AsyncValue) + + # RandomValue generates different values each render when not cached + expect(second_html).not_to eq(first_html) + end + + it "respects :unless option for conditional caching" do + cache_key = "async-unless-test-#{SecureRandom.hex(4)}" + + # With unless: true, should not cache + first_value = cached_async_react_component("RandomValue", cache_key: cache_key, unless: true) { { a: 1 } } + first_html = first_value.value + + second_value = cached_async_react_component("RandomValue", cache_key: cache_key, unless: true) { { a: 1 } } + second_html = second_value.value + + expect(second_html).not_to eq(first_html) + end + + it "raises error when props are passed directly instead of as block" do + expect do + cached_async_react_component("App", cache_key: "test", props: { a: 1 }) + end.to raise_error(ReactOnRailsPro::Error, /Pass 'props' as a block/) + end + + it "raises error when cache_key is missing" do + expect do + cached_async_react_component("App") { { a: 1 } } + end.to raise_error(ReactOnRailsPro::Error, /cache_key.*required/) + end + end + end end # rubocop:enable RSpec/InstanceVariable diff --git a/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb new file mode 100644 index 0000000000..b041c6296c --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "async" +require "async/barrier" + +module ReactOnRailsPro + RSpec.describe AsyncValue do + describe "#value" do + it "returns the task result when task completes successfully" do + Sync do + task = Async do + "
Hello
" + end + + async_value = described_class.new(task: task) + expect(async_value.value).to eq("
Hello
") + end + end + + it "re-raises exception when task fails" do + Sync do + task = Async do + raise StandardError, "Render failed" + end + + async_value = described_class.new(task: task) + expect { async_value.value }.to raise_error(StandardError, "Render failed") + end + end + end + + describe "#resolved?" do + it "returns false when task is not finished" do + Sync do + barrier = Async::Barrier.new + + task = barrier.async do + sleep 0.1 + "result" + end + + async_value = described_class.new(task: task) + expect(async_value.resolved?).to be false + + barrier.wait + end + end + + it "returns true when task is finished" do + Sync do + task = Async do + "result" + end + + task.wait + async_value = described_class.new(task: task) + expect(async_value.resolved?).to be true + end + end + end + + describe "#to_s" do + it "returns the string representation of the value" do + Sync do + task = Async do + "
Content
" + end + + async_value = described_class.new(task: task) + expect(async_value.to_s).to eq("
Content
") + end + end + end + + describe "#html_safe" do + it "returns the html_safe version of the value" do + Sync do + task = Async do + "
Content
" + end + + async_value = described_class.new(task: task) + result = async_value.html_safe + + expect(result).to be_html_safe + expect(result).to eq("
Content
") + end + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb new file mode 100644 index 0000000000..0a4aa7f15c --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +module ReactOnRailsPro + RSpec.describe ImmediateAsyncValue do + describe "#initialize" do + it "stores the value" do + immediate_value = described_class.new("
Cached
") + expect(immediate_value.value).to eq("
Cached
") + end + end + + describe "#value" do + it "returns the stored value immediately" do + immediate_value = described_class.new("
Cached Content
") + expect(immediate_value.value).to eq("
Cached Content
") + end + end + + describe "#resolved?" do + it "always returns true" do + immediate_value = described_class.new("any value") + expect(immediate_value.resolved?).to be true + end + end + + describe "#to_s" do + it "returns the string representation of the value" do + immediate_value = described_class.new("
Content
") + expect(immediate_value.to_s).to eq("
Content
") + end + end + + describe "#html_safe" do + it "returns the html_safe version of the value" do + html_content = "
Content
" + immediate_value = described_class.new(html_content) + result = immediate_value.html_safe + + expect(result).to be_html_safe + expect(result).to eq("
Content
") + end + end + end +end From a52de14faa9bc55226897f9aae21a610b264eff4 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 29 Nov 2025 10:12:21 +0200 Subject: [PATCH 12/21] Update httpx to v1.6.3 (#2141) --- react_on_rails_pro/CHANGELOG.md | 2 ++ react_on_rails_pro/react_on_rails_pro.gemspec | 3 +++ react_on_rails_pro/spec/dummy/Gemfile.lock | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/react_on_rails_pro/CHANGELOG.md b/react_on_rails_pro/CHANGELOG.md index 965e77c7dd..49e8d5a223 100644 --- a/react_on_rails_pro/CHANGELOG.md +++ b/react_on_rails_pro/CHANGELOG.md @@ -48,6 +48,8 @@ _Add changes in master not yet tagged._ ### Fixed +- Fixed compatibility issue with httpx 1.6.x by explicitly requiring http-2 >= 1.1.1. [PR 2141](https://github.com/shakacode/react_on_rails/pull/2141) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + - **Node Renderer Worker Restart**: Fixed "descriptor closed" error that occurred when the node renderer restarts while handling an in-progress request (especially streaming requests). Workers now perform graceful shutdowns: they disconnect from the cluster to stop receiving new requests, wait for active requests to complete, then shut down cleanly. A configurable `gracefulWorkerRestartTimeout` ensures workers are forcibly killed if they don't shut down in time. [PR 1970](https://github.com/shakacode/react_on_rails/pull/1970) by [AbanoubGhadban](https://github.com/AbanoubGhadban). - **Body Duplication Bug On Streaming**: Fixed a bug that happens while streaming if the node renderer connection closed after streaming some chunks to the client. [PR 1995](https://github.com/shakacode/react_on_rails/pull/1995) by [AbanoubGhadban](https://github.com/AbanoubGhadban). diff --git a/react_on_rails_pro/react_on_rails_pro.gemspec b/react_on_rails_pro/react_on_rails_pro.gemspec index 9f488d0e48..55773864f7 100644 --- a/react_on_rails_pro/react_on_rails_pro.gemspec +++ b/react_on_rails_pro/react_on_rails_pro.gemspec @@ -35,6 +35,9 @@ Gem::Specification.new do |s| s.add_runtime_dependency "connection_pool" s.add_runtime_dependency "execjs", "~> 2.9" s.add_runtime_dependency "httpx", "~> 1.5" + # Needed to avoid this bug at httpx versions >= 1.6.0: + # https://github.com/HoneyryderChuck/httpx/issues/118 + s.add_runtime_dependency "http-2", ">= 1.1.1" s.add_runtime_dependency "jwt", "~> 2.7" s.add_runtime_dependency "async", ">= 2.6" s.add_runtime_dependency "rainbow" diff --git a/react_on_rails_pro/spec/dummy/Gemfile.lock b/react_on_rails_pro/spec/dummy/Gemfile.lock index fa6aa67d8f..50e0baeb7e 100644 --- a/react_on_rails_pro/spec/dummy/Gemfile.lock +++ b/react_on_rails_pro/spec/dummy/Gemfile.lock @@ -25,6 +25,7 @@ PATH async (>= 2.6) connection_pool execjs (~> 2.9) + http-2 (>= 1.1.1) httpx (~> 1.5) jwt (~> 2.7) rainbow @@ -193,8 +194,8 @@ GEM fiber-storage logger hashdiff (1.1.0) - http-2 (1.0.2) - httpx (1.5.1) + http-2 (1.1.1) + httpx (1.6.3) http-2 (>= 1.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) From 347b4a3a8da55143979d662d94dbdcf2932656e3 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 30 Nov 2025 20:46:15 +0200 Subject: [PATCH 13/21] Add size-limit packages for bundle size tracking (#2150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add size-limit and its plugins (@size-limit/file, @size-limit/webpack) to enable bundle size measurement - Add `size` and `size:json` npm scripts for local bundle size checking This is a prerequisite for the bundle size CI workflow in PR #2149. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- package.json | 5 +++ pnpm-lock.yaml | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/package.json b/package.json index cd7ce56ee1..519f049274 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@babel/preset-typescript": "^7.27.1", "@eslint/compat": "^1.2.8", "@jest/globals": "^29.7.0", + "@size-limit/file": "^12.0.0", + "@size-limit/webpack": "^12.0.0", "@swc/core": "^1.15.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -59,6 +61,7 @@ "react-dom": "18.0.0", "react-on-rails-rsc": "19.0.2", "redux": "^4.2.1", + "size-limit": "^12.0.0", "stylelint": "^16.14.0", "stylelint-config-standard-scss": "^13.1.0", "swc-loader": "^0.2.6", @@ -84,6 +87,8 @@ "eslint": "eslint", "attw": "attw", "publint": "publint", + "size": "pnpm run build && size-limit", + "size:json": "pnpm run build && size-limit --json", "postinstall": "test -f .lefthook.yml && test -d .git && command -v bundle >/dev/null 2>&1 && bundle exec lefthook install || true" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fa0226d36..bdecb97983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,12 @@ importers: '@jest/globals': specifier: ^29.7.0 version: 29.7.0 + '@size-limit/file': + specifier: ^12.0.0 + version: 12.0.0(size-limit@12.0.0(jiti@2.6.1)) + '@size-limit/webpack': + specifier: ^12.0.0 + version: 12.0.0(@swc/core@1.15.3)(size-limit@12.0.0(jiti@2.6.1)) '@swc/core': specifier: ^1.15.0 version: 1.15.3 @@ -150,6 +156,9 @@ importers: redux: specifier: ^4.2.1 version: 4.2.1 + size-limit: + specifier: ^12.0.0 + version: 12.0.0(jiti@2.6.1) stylelint: specifier: ^16.14.0 version: 16.26.0(typescript@5.9.3) @@ -1409,6 +1418,18 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@size-limit/file@12.0.0': + resolution: {integrity: sha512-OzKYpDzWJ2jo6cAIzVsaPuvzZTmMLDoVCViEvsctmImxpXzwJZcuBEpPohFKKdgVdZuNTU8WstmvywPq55Njdw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.0.0 + + '@size-limit/webpack@12.0.0': + resolution: {integrity: sha512-AB8izqxfPsMtB0Jvfqqd8Q+YTGLlgk2ulePFdAwUKAIu4NCSmbzhwyYPPPrycV4Gm8gA3sf5Udu6diXn5CNaHg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.0.0 + '@swc/core-darwin-arm64@1.15.3': resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} engines: {node: '>=10'} @@ -2146,6 +2167,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + cacheable@2.2.0: resolution: {integrity: sha512-LEJxRqfeomiiRd2t0uON6hxAtgOoWDfY3fugebbz+J3vDLO+SkdfFChQcOHTZhj9SYa9iwE9MGYNX72dKiOE4w==} @@ -3656,6 +3681,10 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3840,6 +3869,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4434,6 +4471,16 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + size-limit@12.0.0: + resolution: {integrity: sha512-JBG8dioIs0m2kHOhs9jD6E/tZKD08vmbf2bfqj/rJyNWqJxk/ZcakixjhYtsqdbi+AKVbfPkt3g2RRZiKaizYA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + jiti: ^2.0.0 + peerDependenciesMeta: + jiti: + optional: true + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -6483,6 +6530,21 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@size-limit/file@12.0.0(size-limit@12.0.0(jiti@2.6.1))': + dependencies: + size-limit: 12.0.0(jiti@2.6.1) + + '@size-limit/webpack@12.0.0(@swc/core@1.15.3)(size-limit@12.0.0(jiti@2.6.1))': + dependencies: + nanoid: 5.1.6 + size-limit: 12.0.0(jiti@2.6.1) + webpack: 5.103.0(@swc/core@1.15.3) + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + '@swc/core-darwin-arm64@1.15.3': optional: true @@ -7299,6 +7361,8 @@ snapshots: buffer-from@1.1.2: {} + bytes-iec@3.1.1: {} + cacheable@2.2.0: dependencies: '@cacheable/memory': 2.0.5 @@ -9242,6 +9306,8 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} + lines-and-columns@1.2.4: {} loader-runner@4.3.1: {} @@ -9390,6 +9456,12 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -10056,6 +10128,16 @@ snapshots: sisteransi@1.0.5: {} + size-limit@12.0.0(jiti@2.6.1): + dependencies: + bytes-iec: 3.1.1 + lilconfig: 3.1.3 + nanospinner: 1.2.2 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + optionalDependencies: + jiti: 2.6.1 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 From 31e540cafdea5e3afefce05b9ce019e8c2140215 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 30 Nov 2025 21:13:48 +0200 Subject: [PATCH 14/21] Add size-limit configuration for bundle size tracking (#2152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `.size-limit.json` with configuration for measuring bundle sizes of all packages - Includes raw, gzip, and brotli measurements - Includes webpack bundled sizes for client imports This is a prerequisite for the bundle size CI workflow in PR #2149. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .size-limit.json | 107 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .size-limit.json diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 0000000000..3eadc0f4ff --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,107 @@ +[ + { + "name": "react-on-rails (raw)", + "path": "packages/react-on-rails/lib/*.js", + "webpack": false, + "gzip": false, + "brotli": false + }, + { + "name": "react-on-rails (gzip)", + "path": "packages/react-on-rails/lib/*.js", + "webpack": false, + "gzip": true + }, + { + "name": "react-on-rails (brotli)", + "path": "packages/react-on-rails/lib/*.js", + "webpack": false, + "brotli": true + }, + { + "name": "react-on-rails-pro (raw)", + "path": "packages/react-on-rails-pro/lib/*.js", + "webpack": false, + "gzip": false, + "brotli": false + }, + { + "name": "react-on-rails-pro (gzip)", + "path": "packages/react-on-rails-pro/lib/*.js", + "webpack": false, + "gzip": true + }, + { + "name": "react-on-rails-pro (brotli)", + "path": "packages/react-on-rails-pro/lib/*.js", + "webpack": false, + "brotli": true + }, + { + "name": "react-on-rails-pro-node-renderer (raw)", + "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", + "webpack": false, + "gzip": false, + "brotli": false + }, + { + "name": "react-on-rails-pro-node-renderer (gzip)", + "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", + "webpack": false, + "gzip": true + }, + { + "name": "react-on-rails-pro-node-renderer (brotli)", + "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", + "webpack": false, + "brotli": true + }, + { + "name": "react-on-rails/client bundled (gzip)", + "path": "packages/react-on-rails/lib/ReactOnRails.client.js", + "import": "ReactOnRails", + "gzip": true + }, + { + "name": "react-on-rails/client bundled (brotli)", + "path": "packages/react-on-rails/lib/ReactOnRails.client.js", + "import": "ReactOnRails", + "brotli": true + }, + { + "name": "react-on-rails-pro/client bundled (gzip)", + "path": "packages/react-on-rails-pro/lib/ReactOnRails.client.js", + "import": "ReactOnRails", + "gzip": true + }, + { + "name": "react-on-rails-pro/client bundled (brotli)", + "path": "packages/react-on-rails-pro/lib/ReactOnRails.client.js", + "import": "ReactOnRails", + "brotli": true + }, + { + "name": "registerServerComponent/client bundled (gzip)", + "path": "packages/react-on-rails-pro/lib/registerServerComponent/client.js", + "import": "registerServerComponent", + "gzip": true + }, + { + "name": "registerServerComponent/client bundled (brotli)", + "path": "packages/react-on-rails-pro/lib/registerServerComponent/client.js", + "import": "registerServerComponent", + "brotli": true + }, + { + "name": "wrapServerComponentRenderer/client bundled (gzip)", + "path": "packages/react-on-rails-pro/lib/wrapServerComponentRenderer/client.js", + "import": "wrapServerComponentRenderer", + "gzip": true + }, + { + "name": "wrapServerComponentRenderer/client bundled (brotli)", + "path": "packages/react-on-rails-pro/lib/wrapServerComponentRenderer/client.js", + "import": "wrapServerComponentRenderer", + "brotli": true + } +] From 31e1e2ebf2904d05245c1ecd881c5f4ac5900024 Mon Sep 17 00:00:00 2001 From: Alexey Romanov Date: Tue, 2 Dec 2025 10:52:48 +0300 Subject: [PATCH 15/21] Rename actionlint.yml workflow to Lint GitHub Actions (#2154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary The workflow name was incorrect. ### Pull Request checklist Purely a CI display change, none of the below apply. - ~[ ] Add/update test to cover these changes~ - ~[ ] Update documentation~ - ~[ ] Update CHANGELOG file~ ## Summary by CodeRabbit * **Chores** * Updated GitHub Actions workflow naming for better clarity. ✏️ Tip: You can customize this high-level summary in your review settings. --- .github/workflows/actionlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 00196ca979..ed5bdb6e89 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -1,4 +1,4 @@ -name: Lint JS and Ruby +name: Lint GitHub Actions on: push: From 237f7b99ee3af28a105bed276c971fd3546670fc Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 2 Dec 2025 11:14:57 +0200 Subject: [PATCH 16/21] Add bundle size CI check using size-limit (#2149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add automated bundle size monitoring to CI using [size-limit](https://github.com/ai/size-limit). This helps prevent unexpected bundle size regressions by comparing PR bundle sizes against the base branch. ### How It Works 1. CI checks out the base branch and measures bundle sizes 2. CI checks out the PR branch and measures bundle sizes 3. Compares sizes - fails if any package increases by more than 0.5KB 4. Posts a size report comment on the PR ### What Gets Measured - **react-on-rails**: Raw, gzip, and brotli compressed sizes - **react-on-rails-pro**: Raw, gzip, and brotli compressed sizes - **react-on-rails-pro-node-renderer**: Raw, gzip, and brotli compressed sizes - **Webpack bundled imports**: Client-side bundle sizes when importing via webpack ### Local Testing ```sh # Check current bundle sizes pnpm run size # Compare your branch against master bin/compare-bundle-sizes ``` ### Bypassing the Check For intentional size increases (e.g., adding new features): ```sh bin/skip-bundle-size-check git add .bundle-size-skip-branch git commit -m "Skip bundle size check for intentional size increase" git push ``` ## Pull Request checklist - [x] Add/update test to cover these changes - N/A (CI workflow) - [x] Update documentation - Added to CONTRIBUTING.md - [x] Update CHANGELOG file ## Summary by CodeRabbit * **New Features** * Automated bundle size CI monitoring compares PR bundle sizes against the base branch and fails if any package exceeds a 0.5KB growth threshold. * Added bypass mechanism for intentional bundle size increases. * **Documentation** * Bundle size checking and comparison commands documented. * Local tooling available to measure and compare package sizes against a base branch. * **Chores** * Bundle size monitoring workflow set up for CI. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Claude --- .bundle-size-skip-branch | 6 + .github/workflows/bundle-size.yml | 115 +++++++++++ CHANGELOG.md | 2 + CONTRIBUTING.md | 63 ++++++ bin/compare-bundle-sizes | 69 +++++++ bin/skip-bundle-size-check | 34 ++++ package.json | 2 +- scripts/bundle-size.mjs | 315 ++++++++++++++++++++++++++++++ 8 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 .bundle-size-skip-branch create mode 100644 .github/workflows/bundle-size.yml create mode 100755 bin/compare-bundle-sizes create mode 100755 bin/skip-bundle-size-check create mode 100644 scripts/bundle-size.mjs diff --git a/.bundle-size-skip-branch b/.bundle-size-skip-branch new file mode 100644 index 0000000000..f8c2fd02f1 --- /dev/null +++ b/.bundle-size-skip-branch @@ -0,0 +1,6 @@ +# This file allows skipping the bundle size CI check for a specific branch. +# When a branch name in this file matches the PR branch, the size check is skipped. +# +# Usage: Run `bin/skip-bundle-size-check` to set the current branch, then commit and push. +# +# This is useful when you have an intentional size increase that exceeds the 0.5KB threshold. diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..3562fc73bd --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,115 @@ +name: Bundle Size + +on: + pull_request: + paths: + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.size-limit.json' + - '.github/workflows/bundle-size.yml' + - '.bundle-size-skip-branch' + +jobs: + check-skip: + runs-on: ubuntu-22.04 + outputs: + skip: ${{ steps.skip-check.outputs.skip }} + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Check if branch should skip size check + id: skip-check + env: + BRANCH: ${{ github.head_ref }} + run: | + SKIP_FILE=".bundle-size-skip-branch" + SKIP_BRANCH=$(grep -v '^[[:space:]]*#' "$SKIP_FILE" 2>/dev/null | grep -v '^[[:space:]]*$' | tr -d '[:space:]' || echo "") + if [ "$SKIP_BRANCH" = "$BRANCH" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::Branch '$BRANCH' is set to skip size check" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + size: + needs: check-skip + if: needs.check-skip.outputs.skip != 'true' + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + env: + CI_JOB_NUMBER: 1 + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + # 1. Get PR's size-limit config first (base branch may not have it) + - name: Checkout PR branch for config + uses: actions/checkout@v4 + + - name: Save size-limit config + run: cp .size-limit.json /tmp/size-limit-config.json + + # 2. Get base branch sizes + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Copy size-limit config to base branch + run: cp /tmp/size-limit-config.json .size-limit.json + + - name: Install base dependencies + run: pnpm install --frozen-lockfile + + - name: Build base branch + run: pnpm run build + + - name: Verify build artifacts + run: | + missing=0 + for pkg in react-on-rails react-on-rails-pro react-on-rails-pro-node-renderer; do + if ! ls packages/$pkg/lib/*.js >/dev/null 2>&1; then + echo "::error::Missing build artifacts in packages/$pkg/lib/" + missing=1 + fi + done + if [ $missing -eq 1 ]; then + exit 1 + fi + echo "All build artifacts verified" + + - name: Measure base branch sizes + run: | + pnpm exec size-limit --json > /tmp/base-sizes.json + echo "Base branch sizes:" + cat /tmp/base-sizes.json + + # 3. Checkout PR and set dynamic limits + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Install PR dependencies + run: pnpm install --frozen-lockfile + + - name: Set dynamic limits (base + 0.5KB) + run: node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + + # 4. Run the action with dynamic limits + - name: Check bundle size + uses: andresz1/size-limit-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + package_manager: pnpm + build_script: build + skip_step: install diff --git a/CHANGELOG.md b/CHANGELOG.md index 9349e3a348..49aa694af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Changes since the last non-beta release. #### Added +- **Bundle Size CI Monitoring**: Added automated bundle size tracking to CI using size-limit. Compares PR bundle sizes against the base branch and fails if any package increases by more than 0.5KB. Includes local comparison tool (`bin/compare-bundle-sizes`) and bypass mechanism (`bin/skip-bundle-size-check`) for intentional size increases. [PR 2149](https://github.com/shakacode/react_on_rails/pull/2149) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + - **Service Dependency Checking for bin/dev**: Added optional `.dev-services.yml` configuration to validate required external services (Redis, PostgreSQL, Elasticsearch, etc.) are running before `bin/dev` starts the development server. Provides clear error messages with start commands and install hints when services are missing. Zero impact if not configured - backwards compatible with all existing installations. [PR 2098](https://github.com/shakacode/react_on_rails/pull/2098) by [justin808](https://github.com/justin808). #### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2df5d7178e..0eb799e57f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -305,6 +305,69 @@ Run only ESLint: pnpm run lint ``` +### Bundle Size Checks + +React on Rails monitors bundle sizes in CI to prevent unexpected size increases. The CI compares your PR's bundle sizes against the base branch and fails if any package increases by more than 0.5KB. + +#### Running Locally + +Check current bundle sizes: + +```sh +pnpm run size +``` + +Get JSON output for programmatic use: + +```sh +pnpm run size:json +``` + +Compare your branch against the base branch: + +```sh +bin/compare-bundle-sizes +``` + +This script automatically: + +1. Stashes any uncommitted changes +2. Checks out and builds the base branch (default: `master`) +3. Checks out and builds your current branch +4. Compares the sizes and shows a detailed report + +Options: + +```sh +bin/compare-bundle-sizes main # Compare against 'main' instead of 'master' +bin/compare-bundle-sizes --hierarchical # Group results by package +``` + +#### Bypassing the Check + +If your PR intentionally increases bundle size (e.g., adding a new feature), you can skip the bundle size check: + +```sh +# Run from your PR branch +bin/skip-bundle-size-check +git add .bundle-size-skip-branch +git commit -m "Skip bundle size check for intentional size increase" +git push +``` + +This sets your branch to skip the size check. The skip only applies to the specific branch name written to `.bundle-size-skip-branch`. + +**Important**: Only skip the check when the size increase is justified. Document why the increase is acceptable in your PR description. + +#### What Gets Measured + +The CI measures sizes for: + +- **react-on-rails**: Raw, gzip, and brotli compressed sizes +- **react-on-rails-pro**: Raw, gzip, and brotli compressed sizes +- **react-on-rails-pro-node-renderer**: Raw, gzip, and brotli compressed sizes +- **Webpack bundled imports**: Client-side bundle sizes when importing via webpack + ### Starting the Dummy App To run the dummy app, it's **CRITICAL** to not just run `rails s`. You have to run `foreman start` with one of the Procfiles. If you don't do this, then `webpack` will not generate a new bundle, and you will be seriously confused when you change JavaScript and the app does not change. If you change the Webpack configs, then you need to restart Foreman. If you change the JS code for react-on-rails, you need to run `pnpm run build` in the project root. diff --git a/bin/compare-bundle-sizes b/bin/compare-bundle-sizes new file mode 100755 index 0000000000..09fb90d296 --- /dev/null +++ b/bin/compare-bundle-sizes @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Compare bundle sizes between current branch and a base branch +# +# Usage: +# bin/compare-bundle-sizes [base-branch] +# +# Arguments: +# base-branch The branch to compare against (default: master) +# +# Examples: +# bin/compare-bundle-sizes # Compare against master +# bin/compare-bundle-sizes develop # Compare against develop +# bin/compare-bundle-sizes feature/some-branch + +set -e + +BASE_BRANCH="${1:-master}" +CURRENT_BRANCH=$(git branch --show-current) +STASHED=false + +# Colors for output +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +cleanup() { + echo -e "\n${BLUE}Cleaning up...${NC}" + git checkout "$CURRENT_BRANCH" --quiet 2>/dev/null || true + if [ "$STASHED" = true ]; then + git stash pop --quiet 2>/dev/null || true + fi +} + +trap cleanup EXIT + +echo -e "${BLUE}📦 Bundle Size Comparison${NC}" +echo -e " Current branch: ${YELLOW}$CURRENT_BRANCH${NC}" +echo -e " Base branch: ${YELLOW}$BASE_BRANCH${NC}" +echo "" + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + echo -e "${YELLOW}Stashing uncommitted changes...${NC}" + git stash push -m "compare-bundle-sizes temp stash" --quiet + STASHED=true +fi + +# Get base branch sizes +echo -e "${BLUE}Building base branch ($BASE_BRANCH)...${NC}" +git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true +git checkout "$BASE_BRANCH" --quiet 2>/dev/null || git checkout "origin/$BASE_BRANCH" --quiet +pnpm install --frozen-lockfile 2>&1 | grep -v "^$" | head -5 || true +pnpm run build 2>&1 | grep -v "^$" | tail -3 || true + +echo -e "${BLUE}Measuring base branch sizes...${NC}" +pnpm exec size-limit --json > /tmp/base-sizes.json + +# Get current branch sizes +echo -e "${BLUE}Building current branch ($CURRENT_BRANCH)...${NC}" +git checkout "$CURRENT_BRANCH" --quiet +pnpm install --frozen-lockfile 2>&1 | grep -v "^$" | head -5 || true +pnpm run build 2>&1 | grep -v "^$" | tail -3 || true + +echo -e "${BLUE}Measuring current branch sizes...${NC}" +pnpm exec size-limit --json > /tmp/current-sizes.json + +# Compare sizes using the bundle-size script +node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json diff --git a/bin/skip-bundle-size-check b/bin/skip-bundle-size-check new file mode 100755 index 0000000000..8c0045b0a7 --- /dev/null +++ b/bin/skip-bundle-size-check @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Skip bundle size check for current branch +# +# Usage: +# bin/skip-bundle-size-check +# + +set -e + +SKIP_FILE=".bundle-size-skip-branch" +BRANCH=$(git branch --show-current) + +if [ -z "$BRANCH" ]; then + echo "Error: Not on a branch (detached HEAD?)" + exit 1 +fi + +# Write comment header and branch name +cat > "$SKIP_FILE" << EOF +# This file allows skipping the bundle size CI check for a specific branch. +# When a branch name in this file matches the PR branch, the size check is skipped. +# +# Usage: Run \`bin/skip-bundle-size-check\` to set the current branch, then commit and push. +# +# This is useful when you have an intentional size increase that exceeds the 0.5KB threshold. +$BRANCH +EOF +echo "Set '$BRANCH' as the branch to skip bundle size check" +echo "" +echo "Next steps:" +echo " git add $SKIP_FILE" +echo " git commit -m 'Skip bundle size check for $BRANCH'" +echo " git push" diff --git a/package.json b/package.json index 519f049274..26d94f213f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@types/react-dom": "^18.3.5", "@types/turbolinks": "^5.2.2", "create-react-class": "^15.7.0", - "globals": "^16.2.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", "eslint-config-shakacode": "^19.0.0", @@ -47,6 +46,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-testing-library": "^7.1.1", + "globals": "^16.2.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-fetch-mock": "^3.0.3", diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs new file mode 100644 index 0000000000..e1a429e8a8 --- /dev/null +++ b/scripts/bundle-size.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Bundle Size Utilities + * + * Commands: + * set-limits - Update .size-limit.json with dynamic limits (base + threshold) + * compare - Compare two size measurements and print a report + * + * Usage: + * node scripts/bundle-size.mjs set-limits --base [--config ] [--threshold ] + * node scripts/bundle-size.mjs compare --base --current [--threshold ] + * + * Examples: + * node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + * node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json + */ + +import fs from 'fs'; + +// Default threshold: 0.5 KB (512 bytes) +// Intentionally strict to catch any bundle size changes early. +// For intentional size increases, use bin/skip-bundle-size-check to bypass the CI check. +const DEFAULT_THRESHOLD = 512; +const DEFAULT_CONFIG = '.size-limit.json'; + +// ANSI color codes +const colors = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m', +}; + +/** + * Format bytes to human-readable string + */ +function formatSize(bytes) { + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} kB`; + } + return `${bytes} B`; +} + +/** + * Format size difference with percentage + */ +function formatDiff(diff, percent) { + if (diff === 0) return '0%'; + const sign = diff > 0 ? '+' : ''; + return `${sign}${formatSize(Math.abs(diff))} (${sign}${percent.toFixed(2)}%)`; +} + +/** + * Parse command line arguments + */ +function parseArgs(args) { + const parsed = { _: [] }; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + parsed[key] = next; + i += 1; + } else { + parsed[key] = true; + } + } else { + parsed._.push(arg); + } + } + return parsed; +} + +/** + * Read and parse JSON file + */ +function readJsonFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); +} + +/** + * Try to read and parse JSON file, exit on error + */ +// eslint-disable-next-line consistent-return +function readJsonFileOrExit(filePath) { + try { + return readJsonFile(filePath); + } catch (error) { + console.error(`${colors.red}Error reading ${filePath}: ${error.message}${colors.reset}`); + process.exit(1); + } +} + +/** + * Command: set-limits + * Updates .size-limit.json with dynamic limits based on base sizes + */ +function setLimits(options) { + const basePath = options.base; + const configPath = options.config || DEFAULT_CONFIG; + const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; + + if (!basePath) { + console.error(`${colors.red}Error: --base is required${colors.reset}`); + process.exit(1); + } + + const baseSizes = readJsonFileOrExit(basePath); + const config = readJsonFileOrExit(configPath); + + console.log(`${colors.blue}Setting dynamic limits (base + ${formatSize(threshold)}):${colors.reset}\n`); + + const updatedConfig = config.map((entry) => { + const baseEntry = baseSizes.find((b) => b.name === entry.name); + if (baseEntry) { + const limit = baseEntry.size + threshold; + console.log(`${entry.name}:`); + console.log(` base size: ${formatSize(baseEntry.size)}`); + console.log(` limit: ${formatSize(limit)}\n`); + return { ...entry, limit: `${limit} B` }; + } + console.log(`${colors.yellow}${entry.name}: No base entry found, keeping original limit${colors.reset}`); + return entry; + }); + + fs.writeFileSync(configPath, `${JSON.stringify(updatedConfig, null, 2)}\n`); + console.log(`${colors.green}Updated ${configPath}${colors.reset}`); +} + +/** + * Get diff color based on threshold + */ +function getDiffColor(diff, threshold) { + if (diff > threshold) return colors.red; + if (diff > 0) return colors.yellow; + return colors.green; +} + +/** + * Print a single result row + */ +function printResultRow(result, maxNameLen, threshold) { + const status = result.exceeded + ? `${colors.red}❌ EXCEEDED${colors.reset}` + : `${colors.green}✅ OK${colors.reset}`; + + const diffColor = getDiffColor(result.diff, threshold); + const diffStr = `${diffColor}${formatDiff(result.diff, result.percent)}${colors.reset}`; + + const namePart = result.name.padEnd(maxNameLen + 2); + const basePart = formatSize(result.baseSize).padStart(12); + const currentPart = formatSize(result.currentSize).padStart(12); + const diffPart = diffStr.padStart(20 + 9); + + console.log(`${namePart}${basePart}${currentPart}${diffPart} ${status}`); +} + +/** + * Command: compare + * Compares two size measurements and prints a report + */ +function compare(options) { + const basePath = options.base; + const currentPath = options.current; + const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; + const json = options.json === true || options.json === 'true'; + + if (!basePath || !currentPath) { + console.error(`${colors.red}Error: --base and --current are required${colors.reset}`); + process.exit(1); + } + + const baseSizes = readJsonFileOrExit(basePath); + const currentSizes = readJsonFileOrExit(currentPath); + + const results = currentSizes.map((current) => { + const base = baseSizes.find((b) => b.name === current.name) || { size: 0 }; + const diff = current.size - base.size; + const percent = base.size > 0 ? (diff / base.size) * 100 : 0; + const exceeded = diff > threshold; + + return { + name: current.name, + baseSize: base.size, + currentSize: current.size, + diff, + percent, + exceeded, + }; + }); + + const hasExceeded = results.some((r) => r.exceeded); + + if (json) { + // JSON output for programmatic use + console.log( + JSON.stringify( + { + threshold, + hasExceeded, + results: results.map((r) => ({ + name: r.name, + baseSize: r.baseSize, + currentSize: r.currentSize, + diff: r.diff, + percentChange: r.percent, + exceeded: r.exceeded, + })), + }, + null, + 2, + ), + ); + } else { + // Pretty table output + const maxNameLen = Math.max(...results.map((r) => r.name.length)); + const separator = '━'.repeat(76); + const thinSeparator = '─'.repeat(maxNameLen + 2 + 12 + 12 + 20 + 12); + + console.log(''); + console.log(`${colors.blue}${separator}${colors.reset}`); + console.log(`${colors.blue}Bundle Size Report${colors.reset}`); + console.log(`${colors.blue}${separator}${colors.reset}`); + console.log(''); + + const header = `${'Package'.padEnd(maxNameLen + 2)}${'Base'.padStart(12)}${'Current'.padStart( + 12, + )}${'Diff'.padStart(20)} Status`; + console.log(header); + console.log(thinSeparator); + + results.forEach((r) => printResultRow(r, maxNameLen, threshold)); + + console.log(''); + console.log(thinSeparator); + console.log(`Threshold: ${formatSize(threshold)} (base + ${formatSize(threshold)})`); + console.log(''); + + if (hasExceeded) { + console.log(`${colors.red}❌ Some packages exceeded the size threshold!${colors.reset}`); + } else { + console.log(`${colors.green}✅ All packages within threshold.${colors.reset}`); + } + } + + if (hasExceeded) { + process.exit(1); + } +} + +/** + * Print usage help + */ +function printHelp() { + console.log(` +${colors.blue}Bundle Size Utilities${colors.reset} + +${colors.yellow}Commands:${colors.reset} + set-limits Update .size-limit.json with dynamic limits + compare Compare two size measurements and print report + +${colors.yellow}Usage:${colors.reset} + node scripts/bundle-size.mjs set-limits --base [options] + node scripts/bundle-size.mjs compare --base --current [options] + +${colors.yellow}Options for set-limits:${colors.reset} + --base Path to base sizes JSON (required) + --config Path to .size-limit.json (default: .size-limit.json) + --threshold Size threshold in bytes (default: 512) + +${colors.yellow}Options for compare:${colors.reset} + --base Path to base sizes JSON (required) + --current Path to current sizes JSON (required) + --threshold Size threshold in bytes (default: 512) + --json Output results as JSON + +${colors.yellow}Examples:${colors.reset} + # Set dynamic limits from base sizes + node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + + # Compare sizes with custom threshold (1KB) + node scripts/bundle-size.mjs compare --base base.json --current current.json --threshold 1024 + + # Get comparison as JSON + node scripts/bundle-size.mjs compare --base base.json --current current.json --json +`); +} + +// Main +const args = parseArgs(process.argv.slice(2)); +const command = args._[0]; + +switch (command) { + case 'set-limits': + setLimits(args); + break; + case 'compare': + compare(args); + break; + case 'help': + case '--help': + case '-h': + printHelp(); + break; + default: + if (command) { + console.error(`${colors.red}Unknown command: ${command}${colors.reset}\n`); + } + printHelp(); + process.exit(command ? 1 : 0); +} From 53ce704b28b75a84374ff7f1e7bb3b784d15c7e0 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 2 Dec 2025 16:43:27 +0200 Subject: [PATCH 17/21] Install @size-limit/time package (#2161) Prerequest for https://github.com/shakacode/react_on_rails/pull/2160 --- package.json | 1 + pnpm-lock.yaml | 534 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 533 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 26d94f213f..29b3e30c29 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@eslint/compat": "^1.2.8", "@jest/globals": "^29.7.0", "@size-limit/file": "^12.0.0", + "@size-limit/time": "^12.0.0", "@size-limit/webpack": "^12.0.0", "@swc/core": "^1.15.0", "@testing-library/dom": "^10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdecb97983..e538e40116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@size-limit/file': specifier: ^12.0.0 version: 12.0.0(size-limit@12.0.0(jiti@2.6.1)) + '@size-limit/time': + specifier: ^12.0.0 + version: 12.0.0(size-limit@12.0.0(jiti@2.6.1)) '@size-limit/webpack': specifier: ^12.0.0 version: 12.0.0(@swc/core@1.15.3)(size-limit@12.0.0(jiti@2.6.1)) @@ -1346,6 +1349,11 @@ packages: resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} + '@puppeteer/browsers@2.10.10': + resolution: {integrity: sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==} + engines: {node: '>=18'} + hasBin: true + '@redis/bloom@5.10.0': resolution: {integrity: sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==} engines: {node: '>= 18'} @@ -1418,12 +1426,22 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sitespeed.io/tracium@0.3.3': + resolution: {integrity: sha512-dNZafjM93Y+F+sfwTO5gTpsGXlnc/0Q+c2+62ViqP3gkMWvHEMSKkaEHgVJLcLg3i/g19GSIPziiKpgyne07Bw==} + engines: {node: '>=8'} + '@size-limit/file@12.0.0': resolution: {integrity: sha512-OzKYpDzWJ2jo6cAIzVsaPuvzZTmMLDoVCViEvsctmImxpXzwJZcuBEpPohFKKdgVdZuNTU8WstmvywPq55Njdw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: size-limit: 12.0.0 + '@size-limit/time@12.0.0': + resolution: {integrity: sha512-MAMr1OK1qEEoxbrYjA79cGzncY6KVhNRx6Hid4L1vZLbR6P+k0Cs8zREzCslS4XtOXqyxMtvIJfpq10MN+pAtg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 12.0.0 + '@size-limit/webpack@12.0.0': resolution: {integrity: sha512-AB8izqxfPsMtB0Jvfqqd8Q+YTGLlgk2ulePFdAwUKAIu4NCSmbzhwyYPPPrycV4Gm8gA3sf5Udu6diXn5CNaHg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1536,6 +1554,9 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tsconfig/node14@14.1.8': resolution: {integrity: sha512-SjGT+qPvh8Uhc849yNMD0ZIPr69AyB7Z46nMqhrI3gCVocd6mhI0jP4YE4onO/ufpmengRfTxNMpdpKEp2xRIg==} @@ -1676,6 +1697,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.48.0': resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1931,6 +1955,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2053,6 +2081,10 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -2083,6 +2115,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2129,10 +2169,52 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.2: + resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + baseline-browser-mapping@2.8.31: resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} hasBin: true + basic-ftp@5.0.5: + resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + engines: {node: '>=10.0.0'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2161,6 +2243,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -2221,6 +2306,11 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromium-bidi@8.0.0: + resolution: {integrity: sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==} + peerDependencies: + devtools-protocol: '*' + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -2285,6 +2375,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@12.0.0: + resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2378,6 +2472,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@2.0.0: resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} engines: {node: '>=10'} @@ -2452,6 +2550,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2464,6 +2566,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + devtools-protocol@0.0.1495869: + resolution: {integrity: sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2811,6 +2916,11 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} + estimo@3.0.5: + resolution: {integrity: sha512-Q9asaAAM3KZc4Ckr8GMcJWYc3hNCf0KnmhkfzHuAWmqGoPssQoe5Mb8et1CYmmkeMfPTlUyeBHRi53Bedvnl1Q==} + engines: {node: '>=18'} + hasBin: true + estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} @@ -2826,6 +2936,9 @@ packages: event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2842,6 +2955,11 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -2854,6 +2972,9 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2895,6 +3016,9 @@ packages: fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2918,6 +3042,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-chrome-bin@2.0.4: + resolution: {integrity: sha512-iKiqIb7FsA0hwnq0vvDay4RsmHUFLvWVquTb59XVlxfHS68XaWZfEjriF2vTZ3k/plicyKZxMJLqxKt10kSOtQ==} + engines: {node: '>=18.0.0'} + find-my-way@9.3.0: resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} engines: {node: '>=20'} @@ -3018,6 +3146,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -3029,6 +3161,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3157,10 +3293,18 @@ packages: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3215,6 +3359,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@2.2.0: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} @@ -3760,6 +3908,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -3845,6 +3997,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -3869,6 +4024,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} @@ -3888,6 +4048,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -4018,6 +4182,14 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} @@ -4067,6 +4239,9 @@ packages: pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4167,6 +4342,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + promise-polyfill@8.3.0: resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==} @@ -4177,6 +4356,13 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + ps-tree@1.2.0: resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} engines: {node: '>= 0.10'} @@ -4197,6 +4383,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + puppeteer-core@24.22.0: + resolution: {integrity: sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w==} + engines: {node: '>=18'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -4493,10 +4683,22 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.5.2: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -4552,6 +4754,9 @@ packages: stream-combiner@0.0.4: resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -4706,6 +4911,12 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + terser-webpack-plugin@5.3.14: resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} @@ -4731,6 +4942,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -4861,6 +5075,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typed-query-selector@2.12.0: + resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} + typescript-eslint@8.48.0: resolution: {integrity: sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4971,6 +5188,9 @@ packages: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} + webdriver-bidi-protocol@0.2.11: + resolution: {integrity: sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5152,10 +5372,16 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} @@ -6460,6 +6686,21 @@ snapshots: '@publint/pack@0.1.2': {} + '@puppeteer/browsers@2.10.10': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.3 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@redis/bloom@5.10.0(@redis/client@5.10.0)': dependencies: '@redis/client': 5.10.0 @@ -6530,10 +6771,28 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sitespeed.io/tracium@0.3.3': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@size-limit/file@12.0.0(size-limit@12.0.0(jiti@2.6.1))': dependencies: size-limit: 12.0.0(jiti@2.6.1) + '@size-limit/time@12.0.0(size-limit@12.0.0(jiti@2.6.1))': + dependencies: + estimo: 3.0.5 + size-limit: 12.0.0(jiti@2.6.1) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + '@size-limit/webpack@12.0.0(@swc/core@1.15.3)(size-limit@12.0.0(jiti@2.6.1))': dependencies: nanoid: 5.1.6 @@ -6631,6 +6890,8 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tsconfig/node14@14.1.8': {} '@tybys/wasm-util@0.10.1': @@ -6802,6 +7063,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.19.25 + optional: true + '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7075,6 +7341,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -7218,6 +7486,10 @@ snapshots: ast-types-flow@0.0.8: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -7239,6 +7511,8 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.7.3: {} + babel-jest@29.7.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -7322,8 +7596,47 @@ snapshots: balanced-match@2.0.0: {} + bare-events@2.8.2: {} + + bare-fs@4.5.2: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + optional: true + baseline-browser-mapping@2.8.31: {} + basic-ftp@5.0.5: {} + boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -7357,6 +7670,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -7413,6 +7728,12 @@ snapshots: chrome-trace-event@1.0.4: {} + chromium-bidi@8.0.0(devtools-protocol@0.0.1495869): + dependencies: + devtools-protocol: 0.0.1495869 + mitt: 3.0.1 + zod: 3.25.76 + ci-info@3.9.0: {} cjs-module-lexer@1.4.3: {} @@ -7478,6 +7799,8 @@ snapshots: commander@10.0.1: {} + commander@12.0.0: {} + commander@2.20.3: {} common-tags@1.8.2: {} @@ -7574,6 +7897,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@6.0.2: {} + data-urls@2.0.0: dependencies: abab: 2.0.6 @@ -7642,12 +7967,20 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} dequal@2.0.3: {} detect-newline@3.1.0: {} + devtools-protocol@0.0.1495869: {} + diff-sequences@29.6.3: {} dir-glob@3.0.1: @@ -8111,6 +8444,21 @@ snapshots: dependencies: estraverse: 5.3.0 + estimo@3.0.5: + dependencies: + '@sitespeed.io/tracium': 0.3.3 + commander: 12.0.0 + find-chrome-bin: 2.0.4 + nanoid: 5.1.5 + puppeteer-core: 24.22.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + estraverse@4.3.0: {} estraverse@5.3.0: {} @@ -8127,6 +8475,12 @@ snapshots: stream-combiner: 0.0.4 through: 2.3.8 + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} execa@5.1.1: @@ -8151,6 +8505,16 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-copy@3.0.2: {} fast-decode-uri-component@1.0.1: {} @@ -8159,6 +8523,8 @@ snapshots: fast-diff@1.3.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8222,6 +8588,10 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -8240,6 +8610,15 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-chrome-bin@2.0.4: + dependencies: + '@puppeteer/browsers': 2.10.10 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + find-my-way@9.3.0: dependencies: fast-deep-equal: 3.1.3 @@ -8358,6 +8737,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-stream@6.0.1: {} get-symbol-description@1.1.0: @@ -8370,6 +8753,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.0.5 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8498,6 +8889,13 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -8505,6 +8903,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.4.24: @@ -8550,6 +8955,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.1.0: {} + ipaddr.js@2.2.0: {} is-array-buffer@3.0.5: @@ -9372,6 +9779,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lz-string@1.5.0: {} make-dir@4.0.0: @@ -9440,6 +9849,8 @@ snapshots: minimist@1.2.8: {} + mitt@3.0.1: {} + mkdirp@1.0.4: {} mock-fs@5.5.0: {} @@ -9456,6 +9867,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.5: {} + nanoid@5.1.6: {} nanospinner@1.2.2: @@ -9468,6 +9881,8 @@ snapshots: neo-async@2.6.2: {} + netmask@2.0.2: {} + node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -9636,6 +10051,24 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + package-manager-detector@1.5.0: {} parent-module@1.0.1: @@ -9677,6 +10110,8 @@ snapshots: dependencies: through: 2.3.8 + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -9795,6 +10230,8 @@ snapshots: process-warning@5.0.0: {} + progress@2.0.3: {} + promise-polyfill@8.3.0: {} prompts@2.4.2: @@ -9808,6 +10245,21 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + ps-tree@1.2.0: dependencies: event-stream: 3.3.4 @@ -9830,6 +10282,23 @@ snapshots: punycode@2.3.1: {} + puppeteer-core@24.22.0: + dependencies: + '@puppeteer/browsers': 2.10.10 + chromium-bidi: 8.0.0(devtools-protocol@0.0.1495869) + debug: 4.4.3 + devtools-protocol: 0.0.1495869 + typed-query-selector: 2.12.0 + webdriver-bidi-protocol: 0.2.11 + ws: 8.18.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + pure-rand@6.1.0: {} qified@0.5.2: @@ -10150,8 +10619,23 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + smart-buffer@4.2.0: {} + smol-toml@1.5.2: {} + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -10206,6 +10690,15 @@ snapshots: dependencies: duplexer: 0.1.2 + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -10420,6 +10913,27 @@ snapshots: tapable@2.3.0: {} + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.2 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.3.14(@swc/core@1.15.3)(webpack@5.103.0(@swc/core@1.15.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -10444,6 +10958,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -10525,8 +11045,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-check@0.4.0: dependencies: @@ -10575,6 +11094,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typed-query-selector@2.12.0: {} + typescript-eslint@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: '@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) @@ -10693,6 +11214,8 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + webdriver-bidi-protocol@0.2.11: {} + webidl-conversions@3.0.1: {} webidl-conversions@5.0.0: {} @@ -10908,6 +11431,13 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} + zod@3.25.76: {} + zod@4.1.13: {} From 5b98f52a2b62438e470da84f5e3a0ff8c7bcc296 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 3 Dec 2025 10:51:59 +0200 Subject: [PATCH 18/21] Update gemfile.lock files (#2162) --- react_on_rails_pro/Gemfile.lock | 3 +- .../spec/execjs-compatible-dummy/Gemfile.lock | 28 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/react_on_rails_pro/Gemfile.lock b/react_on_rails_pro/Gemfile.lock index a3ee4fe390..ae870831de 100644 --- a/react_on_rails_pro/Gemfile.lock +++ b/react_on_rails_pro/Gemfile.lock @@ -25,6 +25,7 @@ PATH async (>= 2.6) connection_pool execjs (~> 2.9) + http-2 (>= 1.1.1) httpx (~> 1.5) jwt (~> 2.7) rainbow @@ -182,7 +183,7 @@ GEM graphiql-rails (1.10.0) railties hashdiff (1.1.0) - http-2 (1.0.2) + http-2 (1.1.1) httpx (1.5.1) http-2 (>= 1.0.0) i18n (1.14.7) diff --git a/react_on_rails_pro/spec/execjs-compatible-dummy/Gemfile.lock b/react_on_rails_pro/spec/execjs-compatible-dummy/Gemfile.lock index 8884ab300d..89b9b3e502 100644 --- a/react_on_rails_pro/spec/execjs-compatible-dummy/Gemfile.lock +++ b/react_on_rails_pro/spec/execjs-compatible-dummy/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../../.. specs: - react_on_rails (16.1.1) + react_on_rails (16.2.0.beta.12) addressable connection_pool execjs (~> 2.5) @@ -12,14 +12,16 @@ PATH PATH remote: ../.. specs: - react_on_rails_pro (4.0.0) + react_on_rails_pro (16.2.0.beta.12) addressable + async (>= 2.6) connection_pool execjs (~> 2.9) + http-2 (>= 1.1.1) httpx (~> 1.5) jwt (~> 2.7) rainbow - react_on_rails (>= 16.0.0) + react_on_rails (= 16.2.0.beta.12) GEM remote: https://rubygems.org/ @@ -97,6 +99,12 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + async (2.35.0) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) @@ -116,6 +124,10 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.0) + console (1.34.2) + fiber-annotation + fiber-local (~> 1.1) + json crass (1.0.6) date (3.4.1) debug (1.9.2) @@ -124,14 +136,19 @@ GEM drb (2.2.1) erubi (1.13.1) execjs (2.9.1) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) globalid (1.2.1) activesupport (>= 6.1) - http-2 (1.0.2) + http-2 (1.1.1) httpx (1.6.2) http-2 (>= 1.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.0) + io-event (1.14.2) irb (1.15.1) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -139,6 +156,7 @@ GEM jbuilder (2.12.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) + json (2.16.0) jwt (2.10.2) base64 logger (1.6.6) @@ -153,6 +171,7 @@ GEM marcel (1.0.4) matrix (0.4.2) method_source (1.1.0) + metrics (0.15.0) mini_mime (1.1.5) mini_portile2 (2.8.8) minitest (5.25.4) @@ -274,6 +293,7 @@ GEM stringio (3.1.2) thor (1.3.2) timeout (0.4.3) + traces (0.18.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) useragent (0.16.11) From dbf766d498ba1ff54a44df1dcfb545e13b3b32d2 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 4 Dec 2025 10:01:24 +0200 Subject: [PATCH 19/21] Add running time check on ci (#2165) # Summary - This PR started as an attempt to make CI use the `@size-limit/time` plugin to measure the execution time of each client bundle and ensure it doesn't exceed the base branch execution time by more than 20%. - Later, found that the `@size-limit/time` plugin is not good at all at detecting that because of the runtime noise on CI. - So, CI will only look for the `download time` of chunks at the browser for now, until finding another tool that run the code multiple time on CI and get average execution time to get rid of runtime noise. - However, the following changes are made: - The feature of measring the execution time and comparing it to the base branch is already added to the `bin/compare-bundle-sizes` script, so if you can run it locally with a noise mitigated environment you can do that by changing the `running` values at `.size-limit.json` file to `true`. - Removed the `compare` command from the `scripts/bundle-size.mjs` script as it's not needed and used the default comparison behavior at the `size-limit` package. --- .github/workflows/bundle-size.yml | 7 +- .size-limit.json | 81 +++--------- CHANGELOG.md | 2 + CONTRIBUTING.md | 2 +- bin/compare-bundle-sizes | 9 +- package.json | 1 + scripts/bundle-size.mjs | 208 +++++++----------------------- 7 files changed, 80 insertions(+), 230 deletions(-) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 3562fc73bd..46b93f713f 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -33,7 +33,7 @@ jobs: echo "skip=false" >> $GITHUB_OUTPUT fi - size: + check-bundle-size: needs: check-skip if: needs.check-skip.outputs.skip != 'true' runs-on: ubuntu-22.04 @@ -105,11 +105,14 @@ jobs: - name: Set dynamic limits (base + 0.5KB) run: node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json + - name: Copy .size-limit.json file with limits to tmp directory + run: cp .size-limit.json /tmp/.size-limit-with-limits.json + # 4. Run the action with dynamic limits - name: Check bundle size uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} package_manager: pnpm - build_script: build + build_script: build-for-size-limit skip_step: install diff --git a/.size-limit.json b/.size-limit.json index 3eadc0f4ff..052c1c41b8 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,107 +1,58 @@ [ - { - "name": "react-on-rails (raw)", - "path": "packages/react-on-rails/lib/*.js", - "webpack": false, - "gzip": false, - "brotli": false - }, - { - "name": "react-on-rails (gzip)", - "path": "packages/react-on-rails/lib/*.js", - "webpack": false, - "gzip": true - }, - { - "name": "react-on-rails (brotli)", - "path": "packages/react-on-rails/lib/*.js", - "webpack": false, - "brotli": true - }, - { - "name": "react-on-rails-pro (raw)", - "path": "packages/react-on-rails-pro/lib/*.js", - "webpack": false, - "gzip": false, - "brotli": false - }, - { - "name": "react-on-rails-pro (gzip)", - "path": "packages/react-on-rails-pro/lib/*.js", - "webpack": false, - "gzip": true - }, - { - "name": "react-on-rails-pro (brotli)", - "path": "packages/react-on-rails-pro/lib/*.js", - "webpack": false, - "brotli": true - }, - { - "name": "react-on-rails-pro-node-renderer (raw)", - "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", - "webpack": false, - "gzip": false, - "brotli": false - }, - { - "name": "react-on-rails-pro-node-renderer (gzip)", - "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", - "webpack": false, - "gzip": true - }, - { - "name": "react-on-rails-pro-node-renderer (brotli)", - "path": "packages/react-on-rails-pro-node-renderer/lib/*.js", - "webpack": false, - "brotli": true - }, { "name": "react-on-rails/client bundled (gzip)", "path": "packages/react-on-rails/lib/ReactOnRails.client.js", "import": "ReactOnRails", - "gzip": true + "gzip": true, + "running": false }, { "name": "react-on-rails/client bundled (brotli)", "path": "packages/react-on-rails/lib/ReactOnRails.client.js", "import": "ReactOnRails", - "brotli": true + "brotli": true, + "running": false }, { "name": "react-on-rails-pro/client bundled (gzip)", "path": "packages/react-on-rails-pro/lib/ReactOnRails.client.js", "import": "ReactOnRails", - "gzip": true + "gzip": true, + "running": false }, { "name": "react-on-rails-pro/client bundled (brotli)", "path": "packages/react-on-rails-pro/lib/ReactOnRails.client.js", "import": "ReactOnRails", - "brotli": true + "brotli": true, + "running": false }, { "name": "registerServerComponent/client bundled (gzip)", "path": "packages/react-on-rails-pro/lib/registerServerComponent/client.js", "import": "registerServerComponent", - "gzip": true + "gzip": true, + "running": false }, { "name": "registerServerComponent/client bundled (brotli)", "path": "packages/react-on-rails-pro/lib/registerServerComponent/client.js", "import": "registerServerComponent", - "brotli": true + "brotli": true, + "running": false }, { "name": "wrapServerComponentRenderer/client bundled (gzip)", "path": "packages/react-on-rails-pro/lib/wrapServerComponentRenderer/client.js", "import": "wrapServerComponentRenderer", - "gzip": true + "gzip": true, + "running": false }, { "name": "wrapServerComponentRenderer/client bundled (brotli)", "path": "packages/react-on-rails-pro/lib/wrapServerComponentRenderer/client.js", "import": "wrapServerComponentRenderer", - "brotli": true + "brotli": true, + "running": false } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 49aa694af2..a59ebacd2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Changes since the last non-beta release. #### Added +- **Download Time CI Monitoring**: Added automated download time tracking to CI using size-limit. Compares PR client import download times against the base branch and fails if any import increases by more than 10%. And added ability to compare the total execution time (download + running time) to the `bin/compare-bundle-sizes` script. [PR 2160](https://github.com/shakacode/react_on_rails/pull/2160) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + - **Bundle Size CI Monitoring**: Added automated bundle size tracking to CI using size-limit. Compares PR bundle sizes against the base branch and fails if any package increases by more than 0.5KB. Includes local comparison tool (`bin/compare-bundle-sizes`) and bypass mechanism (`bin/skip-bundle-size-check`) for intentional size increases. [PR 2149](https://github.com/shakacode/react_on_rails/pull/2149) by [AbanoubGhadban](https://github.com/AbanoubGhadban). - **Service Dependency Checking for bin/dev**: Added optional `.dev-services.yml` configuration to validate required external services (Redis, PostgreSQL, Elasticsearch, etc.) are running before `bin/dev` starts the development server. Provides clear error messages with start commands and install hints when services are missing. Zero impact if not configured - backwards compatible with all existing installations. [PR 2098](https://github.com/shakacode/react_on_rails/pull/2098) by [justin808](https://github.com/justin808). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0eb799e57f..9e3ea4f525 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -334,7 +334,7 @@ This script automatically: 1. Stashes any uncommitted changes 2. Checks out and builds the base branch (default: `master`) 3. Checks out and builds your current branch -4. Compares the sizes and shows a detailed report +4. Compares the size and total execution time (loading + running) and shows a detailed report Options: diff --git a/bin/compare-bundle-sizes b/bin/compare-bundle-sizes index 09fb90d296..fb56029b45 100755 --- a/bin/compare-bundle-sizes +++ b/bin/compare-bundle-sizes @@ -25,6 +25,7 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color cleanup() { + git restore .size-limit.json echo -e "\n${BLUE}Cleaning up...${NC}" git checkout "$CURRENT_BRANCH" --quiet 2>/dev/null || true if [ "$STASHED" = true ]; then @@ -62,8 +63,8 @@ git checkout "$CURRENT_BRANCH" --quiet pnpm install --frozen-lockfile 2>&1 | grep -v "^$" | head -5 || true pnpm run build 2>&1 | grep -v "^$" | tail -3 || true -echo -e "${BLUE}Measuring current branch sizes...${NC}" -pnpm exec size-limit --json > /tmp/current-sizes.json +echo -e "${BLUE}Updating limits of current branch relative to the results of the base branch...${NC}" +node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json -# Compare sizes using the bundle-size script -node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json +# Run size-limit that will use the dynamic limits created after checking base branch results +pnpm exec size-limit diff --git a/package.json b/package.json index 29b3e30c29..d7a9556b9c 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "start": "nps", "nps": "nps", "build": "pnpm --filter react-on-rails run build && pnpm --filter react-on-rails-pro run build && pnpm --filter react-on-rails-pro-node-renderer run build", + "build-for-size-limit": "cp /tmp/.size-limit-with-limits.json .size-limit.json && pnpm build", "build-watch": "pnpm -r run build-watch", "lint": "nps eslint", "lint:scss": "stylelint \"react_on_rails/spec/dummy/app/assets/stylesheets/**/*.scss\" \"react_on_rails/spec/dummy/client/**/*.scss\"", diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs index e1a429e8a8..5edab8252c 100644 --- a/scripts/bundle-size.mjs +++ b/scripts/bundle-size.mjs @@ -4,15 +4,12 @@ * * Commands: * set-limits - Update .size-limit.json with dynamic limits (base + threshold) - * compare - Compare two size measurements and print a report * * Usage: * node scripts/bundle-size.mjs set-limits --base [--config ] [--threshold ] - * node scripts/bundle-size.mjs compare --base --current [--threshold ] * * Examples: * node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json - * node scripts/bundle-size.mjs compare --base /tmp/base-sizes.json --current /tmp/current-sizes.json */ import fs from 'fs'; @@ -21,6 +18,9 @@ import fs from 'fs'; // Intentionally strict to catch any bundle size changes early. // For intentional size increases, use bin/skip-bundle-size-check to bypass the CI check. const DEFAULT_THRESHOLD = 512; +// 10% is a big ratio, but the current approach is not accurate enough to detect rations less than that +// Later, we will implement performance tests that will use more accurate mechanisms and can detect smaller performance regressions +const DEFAULT_TIME_PERCENTAGE_THRESHOLD = 0.1; const DEFAULT_CONFIG = '.size-limit.json'; // ANSI color codes @@ -43,12 +43,13 @@ function formatSize(bytes) { } /** - * Format size difference with percentage + * Format time to human-readable string */ -function formatDiff(diff, percent) { - if (diff === 0) return '0%'; - const sign = diff > 0 ? '+' : ''; - return `${sign}${formatSize(Math.abs(diff))} (${sign}${percent.toFixed(2)}%)`; +function formatTime(ms) { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(2)} s`; + } + return `${ms.toFixed(0)} ms`; } /** @@ -95,6 +96,32 @@ function readJsonFileOrExit(filePath) { } } +function createLimitEntry(entry, baseEntry, threshold, timePercentageThreshold) { + const limit = baseEntry.size + threshold; + console.log(`${entry.name}:`); + console.log(` base size: ${formatSize(baseEntry.size)}`); + console.log(` limit: ${formatSize(limit)}\n`); + const sizeLimitEntry = { ...entry, limit: `${limit} B` }; + if (!baseEntry.running && !baseEntry.loading) { + return sizeLimitEntry; + } + + const { loading = 0, running = 0 } = baseEntry; + const loadingMs = loading * 1000; + const runningMs = running * 1000; + console.log(` base loading time: ${formatTime(loadingMs)}`); + console.log(` base running time: ${formatTime(runningMs)}`); + const totalTime = loadingMs + runningMs; + return [ + sizeLimitEntry, + { + ...entry, + name: `${entry.name} (time)`, + limit: `${(totalTime * (1 + timePercentageThreshold)).toFixed(0)} ms`, + }, + ]; +} + /** * Command: set-limits * Updates .size-limit.json with dynamic limits based on base sizes @@ -103,6 +130,8 @@ function setLimits(options) { const basePath = options.base; const configPath = options.config || DEFAULT_CONFIG; const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; + const timePercentageThreshold = + Number(options.timePercentageThreshold) || DEFAULT_TIME_PERCENTAGE_THRESHOLD; if (!basePath) { console.error(`${colors.red}Error: --base is required${colors.reset}`); @@ -114,144 +143,23 @@ function setLimits(options) { console.log(`${colors.blue}Setting dynamic limits (base + ${formatSize(threshold)}):${colors.reset}\n`); - const updatedConfig = config.map((entry) => { - const baseEntry = baseSizes.find((b) => b.name === entry.name); - if (baseEntry) { - const limit = baseEntry.size + threshold; - console.log(`${entry.name}:`); - console.log(` base size: ${formatSize(baseEntry.size)}`); - console.log(` limit: ${formatSize(limit)}\n`); - return { ...entry, limit: `${limit} B` }; - } - console.log(`${colors.yellow}${entry.name}: No base entry found, keeping original limit${colors.reset}`); - return entry; - }); + const updatedConfig = config + .map((entry) => { + const baseEntry = baseSizes.find((b) => b.name === entry.name); + if (baseEntry) { + return createLimitEntry(entry, baseEntry, threshold, timePercentageThreshold); + } + console.log( + `${colors.yellow}${entry.name}: No base entry found, keeping original limit${colors.reset}`, + ); + return entry; + }) + .flat(); fs.writeFileSync(configPath, `${JSON.stringify(updatedConfig, null, 2)}\n`); console.log(`${colors.green}Updated ${configPath}${colors.reset}`); } -/** - * Get diff color based on threshold - */ -function getDiffColor(diff, threshold) { - if (diff > threshold) return colors.red; - if (diff > 0) return colors.yellow; - return colors.green; -} - -/** - * Print a single result row - */ -function printResultRow(result, maxNameLen, threshold) { - const status = result.exceeded - ? `${colors.red}❌ EXCEEDED${colors.reset}` - : `${colors.green}✅ OK${colors.reset}`; - - const diffColor = getDiffColor(result.diff, threshold); - const diffStr = `${diffColor}${formatDiff(result.diff, result.percent)}${colors.reset}`; - - const namePart = result.name.padEnd(maxNameLen + 2); - const basePart = formatSize(result.baseSize).padStart(12); - const currentPart = formatSize(result.currentSize).padStart(12); - const diffPart = diffStr.padStart(20 + 9); - - console.log(`${namePart}${basePart}${currentPart}${diffPart} ${status}`); -} - -/** - * Command: compare - * Compares two size measurements and prints a report - */ -function compare(options) { - const basePath = options.base; - const currentPath = options.current; - const threshold = parseInt(options.threshold, 10) || DEFAULT_THRESHOLD; - const json = options.json === true || options.json === 'true'; - - if (!basePath || !currentPath) { - console.error(`${colors.red}Error: --base and --current are required${colors.reset}`); - process.exit(1); - } - - const baseSizes = readJsonFileOrExit(basePath); - const currentSizes = readJsonFileOrExit(currentPath); - - const results = currentSizes.map((current) => { - const base = baseSizes.find((b) => b.name === current.name) || { size: 0 }; - const diff = current.size - base.size; - const percent = base.size > 0 ? (diff / base.size) * 100 : 0; - const exceeded = diff > threshold; - - return { - name: current.name, - baseSize: base.size, - currentSize: current.size, - diff, - percent, - exceeded, - }; - }); - - const hasExceeded = results.some((r) => r.exceeded); - - if (json) { - // JSON output for programmatic use - console.log( - JSON.stringify( - { - threshold, - hasExceeded, - results: results.map((r) => ({ - name: r.name, - baseSize: r.baseSize, - currentSize: r.currentSize, - diff: r.diff, - percentChange: r.percent, - exceeded: r.exceeded, - })), - }, - null, - 2, - ), - ); - } else { - // Pretty table output - const maxNameLen = Math.max(...results.map((r) => r.name.length)); - const separator = '━'.repeat(76); - const thinSeparator = '─'.repeat(maxNameLen + 2 + 12 + 12 + 20 + 12); - - console.log(''); - console.log(`${colors.blue}${separator}${colors.reset}`); - console.log(`${colors.blue}Bundle Size Report${colors.reset}`); - console.log(`${colors.blue}${separator}${colors.reset}`); - console.log(''); - - const header = `${'Package'.padEnd(maxNameLen + 2)}${'Base'.padStart(12)}${'Current'.padStart( - 12, - )}${'Diff'.padStart(20)} Status`; - console.log(header); - console.log(thinSeparator); - - results.forEach((r) => printResultRow(r, maxNameLen, threshold)); - - console.log(''); - console.log(thinSeparator); - console.log(`Threshold: ${formatSize(threshold)} (base + ${formatSize(threshold)})`); - console.log(''); - - if (hasExceeded) { - console.log(`${colors.red}❌ Some packages exceeded the size threshold!${colors.reset}`); - } else { - console.log(`${colors.green}✅ All packages within threshold.${colors.reset}`); - } - } - - if (hasExceeded) { - process.exit(1); - } -} - /** * Print usage help */ @@ -261,32 +169,19 @@ ${colors.blue}Bundle Size Utilities${colors.reset} ${colors.yellow}Commands:${colors.reset} set-limits Update .size-limit.json with dynamic limits - compare Compare two size measurements and print report ${colors.yellow}Usage:${colors.reset} node scripts/bundle-size.mjs set-limits --base [options] - node scripts/bundle-size.mjs compare --base --current [options] ${colors.yellow}Options for set-limits:${colors.reset} --base Path to base sizes JSON (required) --config Path to .size-limit.json (default: .size-limit.json) --threshold Size threshold in bytes (default: 512) - -${colors.yellow}Options for compare:${colors.reset} - --base Path to base sizes JSON (required) - --current Path to current sizes JSON (required) - --threshold Size threshold in bytes (default: 512) - --json Output results as JSON + --timePercentageThreshold Acceptable increase percentage in total time ${colors.yellow}Examples:${colors.reset} # Set dynamic limits from base sizes node scripts/bundle-size.mjs set-limits --base /tmp/base-sizes.json - - # Compare sizes with custom threshold (1KB) - node scripts/bundle-size.mjs compare --base base.json --current current.json --threshold 1024 - - # Get comparison as JSON - node scripts/bundle-size.mjs compare --base base.json --current current.json --json `); } @@ -298,9 +193,6 @@ switch (command) { case 'set-limits': setLimits(args); break; - case 'compare': - compare(args); - break; case 'help': case '--help': case '-h': From 758bf0b01c81751fd643209d0fa51f95fbcea0ea Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 4 Dec 2025 15:12:58 +0200 Subject: [PATCH 20/21] add server actions implementation plan (#2166) --- .../01-initial-plan.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/planning/server-functions-implementation/01-initial-plan.md diff --git a/docs/planning/server-functions-implementation/01-initial-plan.md b/docs/planning/server-functions-implementation/01-initial-plan.md new file mode 100644 index 0000000000..27f5643cd5 --- /dev/null +++ b/docs/planning/server-functions-implementation/01-initial-plan.md @@ -0,0 +1,27 @@ +# Server Functions Initial Implementation Plan + +## Definition + +Server Functions allow Client Components to call async functions executed on the server. + +## Other Frameworks Implementation Way + +### Next.js + +Next.js framework re-render the whole app when a server action is executed, so all server components re-render and get latest server state. + +### Waku + +It executes the server function without re-rendering the app. + +### React Implementation Sample + +- Exists at [flight example on react repo](https://github.com/facebook/react/tree/main/fixtures/flight) +- Rerenders the whole app on server action execution + +## Implementation Steps + +1. Add support for registering server actions and transforming rsc and client bundles at `react-on-rails-rsc` webpack loader. Seems that the webpack loader currently doesn't transform the server functions on the client bundle. However, the react node loader at React repo seems that it looks for the `"user server"` directive and transform the server functions. Debug to find out why the server functions are not transformed inside react on rails pro dummy app client bundle. +1. Implement `callServer` function on client side that generates the server action id, encodes the server action arguments and send them to the back-end. +1. Implement backend endpoint to receive the server action requests, decode it, execute and return the result. You need to decide if the whole app should be rendered or not. +1. Ensure progressive enhancement of the form works (when the page is not hydrated yet, form actions can still be submitted). From cbf76c14f99286710000e86970095934310bbe04 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 4 Dec 2025 16:38:56 +0200 Subject: [PATCH 21/21] Fix buildConsoleReplay test parameter order regression (#2168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the failing `buildConsoleReplay.test.js` tests by correcting the parameter order in test calls. ## Problem Commit c3a02254a (Phase 5: Add Pro Node Renderer Package to workspace #2069) incorrectly changed test calls from: ```javascript buildConsoleReplay(undefined, 0, 'abc123') // correct ``` to: ```javascript buildConsoleReplay(0, undefined, 'abc123') // incorrect ``` The function signature is `buildConsoleReplay(customConsoleHistory, numberOfMessagesToSkip, nonce)`, so passing `0` as the first parameter caused: 1. `customConsoleHistory = 0` instead of `undefined` 2. Nullish coalescing `0 ?? console.history` returns `0` (not `console.history`) 3. `!Array.isArray(0)` returns `true`, causing early return of `''` ## Fix Restored the correct parameter order in 3 test calls (lines 81, 90, 115). ## Test plan - [x] `pnpm jest tests/buildConsoleReplay.test.js` - All 14 tests pass Fixes #2167 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Tests** * Updated test cases for console replay functionality to reflect refined argument patterns while maintaining existing validation expectations. ✏️ Tip: You can customize this high-level summary in your review settings. Co-authored-by: Claude --- packages/react-on-rails/tests/buildConsoleReplay.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-on-rails/tests/buildConsoleReplay.test.js b/packages/react-on-rails/tests/buildConsoleReplay.test.js index fc7d9b4f7b..d0e897da76 100644 --- a/packages/react-on-rails/tests/buildConsoleReplay.test.js +++ b/packages/react-on-rails/tests/buildConsoleReplay.test.js @@ -78,7 +78,7 @@ console.warn.apply(console, ["other message","{\\"c\\":3,\\"d\\":4}"]); it('buildConsoleReplay adds nonce attribute when provided', () => { console.history = [{ arguments: ['test message'], level: 'log' }]; - const actual = buildConsoleReplay(0, undefined, 'abc123'); + const actual = buildConsoleReplay(undefined, 0, 'abc123'); expect(actual).toContain('nonce="abc123"'); expect(actual).toContain('