diff --git a/CLAUDE.md b/CLAUDE.md index af69392b38..3a02089dd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,20 @@ 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 +- **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. + +--- + ## 🚨 AVOIDING CI FAILURE CYCLES **CRITICAL**: Large-scale changes (directory structure, configs, workflows) require comprehensive local testing BEFORE pushing. 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/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.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx index 2c5f342d92..4dd6c26570 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.client.jsx @@ -1,11 +1,17 @@ // Top level component for simple client side only rendering import React from 'react'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; // This works fine, React functional component: // export default (props) => ; -export default (props) => ; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering +export default (props) => ( + + + +); // Note, the server side has to be a Render-Function diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetApp.server.jsx index f7c77e8a73..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,7 +1,7 @@ // 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'; /* @@ -16,12 +16,21 @@ 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( + + + , + ); + + const { helmet } = helmetContext; const renderedHtml = { componentHtml, - title: helmet.title.toString(), + title: helmet ? helmet.title.toString() : '', }; // Note that this function returns an Object for server rendering. diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx index 2c5f342d92..4dd6c26570 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.client.jsx @@ -1,11 +1,17 @@ // Top level component for simple client side only rendering import React from 'react'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; // This works fine, React functional component: // export default (props) => ; -export default (props) => ; +// HelmetProvider is required by @dr.pogodin/react-helmet for both client and server rendering +export default (props) => ( + + + +); // Note, the server side has to be a Render-Function diff --git a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx index d1c72af50d..f0d68d3e18 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReactHelmetAppBroken.server.jsx @@ -3,7 +3,7 @@ // function. The point of this is to provide a good error. import React from 'react'; import { renderToString } from 'react-dom/server'; -import { Helmet } from 'react-helmet'; +import { HelmetProvider } from '@dr.pogodin/react-helmet'; import ReactHelmet from '../components/ReactHelmet'; /* @@ -18,12 +18,17 @@ import ReactHelmet from '../components/ReactHelmet'; * Alternately, the function could get the property of `.renderFunction = true` added to it. */ export default (props) => { - const componentHtml = renderToString(); - const helmet = Helmet.renderStatic(); + const helmetContext = {}; + const componentHtml = renderToString( + + + , + ); + const { helmet } = helmetContext; const renderedHtml = { componentHtml, - title: helmet.title.toString(), + title: helmet ? helmet.title.toString() : '', }; return { renderedHtml }; }; diff --git a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx index 3e8ddc211b..cfe9e24a93 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx @@ -5,7 +5,7 @@ import React from 'react'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; -import thunkMiddleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import ReactDOMClient from 'react-dom/client'; import reducers from '../reducers/reducersIndex'; @@ -34,7 +34,7 @@ export default (props, railsContext, domNodeId) => { // This is where we'll put in the middleware for the async function. Placeholder. // store will have helloWorldData as a top level property - const store = createStore(combinedReducer, combinedProps, applyMiddleware(thunkMiddleware)); + const store = createStore(combinedReducer, combinedProps, applyMiddleware(thunk)); // renderApp is a function required for hot reloading. see // https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js diff --git a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx index bdcc317962..a26b2969f0 100644 --- a/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx +++ b/react_on_rails/spec/dummy/client/app/startup/ReduxApp.server.jsx @@ -6,7 +6,7 @@ import React from 'react'; import { combineReducers, applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; -import middleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; // Uses the index import reducers from '../reducers/reducersIndex'; @@ -28,7 +28,7 @@ export default (props, railsContext) => { // This is where we'll put in the middleware for the async function. Placeholder. // store will have helloWorldData as a top level property - const store = applyMiddleware(middleware)(createStore)(combinedReducer, combinedProps); + const store = applyMiddleware(thunk)(createStore)(combinedReducer, combinedProps); // Provider uses the this.props.children, so we're not typical React syntax. // This allows redux to add additional props to the HelloWorldContainer. diff --git a/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx b/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx index 33dcc680ed..3d463ff891 100644 --- a/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx +++ b/react_on_rails/spec/dummy/client/app/stores/SharedReduxStore.jsx @@ -1,5 +1,5 @@ import { combineReducers, applyMiddleware, createStore } from 'redux'; -import middleware from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import reducers from '../reducers/reducersIndex'; @@ -12,5 +12,5 @@ export default (props, railsContext) => { delete props.prerender; const combinedReducer = combineReducers(reducers); const newProps = { ...props, railsContext }; - return applyMiddleware(middleware)(createStore)(combinedReducer, newProps); + return applyMiddleware(thunk)(createStore)(combinedReducer, newProps); }; diff --git a/react_on_rails/spec/dummy/package.json b/react_on_rails/spec/dummy/package.json index e9998a52db..84c0436be3 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", 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/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/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/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 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", 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": "(?:\\"|[^"])*",/,