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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions docs/getting-started/installation-into-an-existing-rails-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,43 @@

**If you have Rails 5 API only project**, first [convert the Rails 5 API only app to a normal Rails app](../migrating/convert-rails-5-api-only-app.md).

1. Add the following to your Gemfile and `bundle install`. We recommend fixing the version of React on Rails, as you will need to keep the exact version in sync with the version in your `package.json` file.
1. Add the following to your Gemfile and run `bundle install`.
We recommend fixing exact versions, as the gem and npm package versions should stay in sync.
For pre-release versions, gems use periods (for example, `16.4.0.rc.5`) and npm packages use dashes
(for example, `16.4.0-rc.5`).

```ruby
gem "shakapacker", "7.0.1" # Use the latest and the exact version
gem "react_on_rails", "13.3.1" # Use the latest and the exact version
gem "shakapacker", "<shakapacker_version>"
gem "react_on_rails", "<react_on_rails_gem_version>"
```

Or use `bundle add`:

```bash
bundle add shakapacker --version=7.0.1 --strict
bundle add react_on_rails --version=13.3.1 --strict
bundle add shakapacker --version="<shakapacker_version>" --strict
bundle add react_on_rails --version="<react_on_rails_gem_version>" --strict
```

2. Run the following 2 commands to install Shakapacker with React. Note, if you are using an older version of Rails than 5.1, you'll need to install Webpacker with React per the instructions [here](https://github.com/rails/webpacker).
2. Run the following command to install Shakapacker with React. Note, if you are using an older version of
Rails than 5.1, you'll need to install Webpacker with React per the instructions
[here](https://github.com/rails/webpacker).

```bash
rails shakapacker:install
bundle exec rails shakapacker:install
```

3. Commit this to git (or else you cannot run the generator unless you pass the option `--ignore-warnings`).

4. Run the generator with a simple "Hello World" example (more options below):

```bash
rails generate react_on_rails:install
bundle exec rails generate react_on_rails:install
```

For more information about this generator use `--help` option:

```bash
rails generate react_on_rails:install --help
bundle exec rails generate react_on_rails:install --help
```

5. Ensure that you have `overmind` or `foreman` installed.
Expand All @@ -48,7 +53,11 @@
./bin/dev
```

Note: `foreman` defaults to PORT 5000 unless you set the value of PORT in your environment. For example, you can `export PORT=3000` to use the Rails default port of 3000. For the hello_world example, this is already set.
If port 3000 is already in use, set an explicit port:

```bash
PORT=3001 ./bin/dev
```

7. Visit [localhost:3000/hello_world](http://localhost:3000/hello_world).

Expand Down
23 changes: 18 additions & 5 deletions packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ export async function buildVM(filePath: string) {
return Promise.resolve(true);
}

// Create a new promise for this VM creation
// Create the VM creation promise. The IIFE runs synchronously until its first
// `await`, so we must store it in the map immediately after creation — before
// the microtask queue is drained — to prevent concurrent callers from starting
// a duplicate build. Cleanup uses `.finally()` on the stored promise rather
// than a try/finally inside the IIFE, because an IIFE's finally block can
// execute synchronously (before `vmCreationPromises.set`) when the code throws
// before the first `await`, which would leave a stale rejected promise in the map.
const vmCreationPromise = (async () => {
try {
const { supportModules, stubTimers, additionalContext } = getConfig();
Expand Down Expand Up @@ -335,15 +341,22 @@ export async function buildVM(filePath: string) {
log.error({ error }, 'Caught Error when creating context in buildVM');
errorReporter.error(error as Error);
throw error;
} finally {
// Always remove the promise from the map when done
vmCreationPromises.delete(filePath);
}
})();

// Store the promise
// Store the promise BEFORE any async work completes, so concurrent callers
// find it via the has() check above.
vmCreationPromises.set(filePath, vmCreationPromise);

// Remove from the map after the promise settles. Using .finally() on the
// stored promise guarantees this runs after set(), unlike a try/finally
// inside the IIFE which can run synchronously before set() on sync throws.
void vmCreationPromise
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .catch(() => {}).finally(...) chain is the right pattern here — the .catch is necessary to prevent an unhandled rejection on the void-ed chain (since .finally() on a rejected promise propagates the rejection through the tail). The comment could be a touch more explicit about this:

// Suppress the rejection on this internal chain so it doesn't surface as an
// unhandled rejection when the outer `void` discards the tail promise.
// The original `vmCreationPromise` is still returned to callers and will
// reject normally.
void vmCreationPromise
  .catch(() => {})
  .finally(() => {
    vmCreationPromises.delete(filePath);
  });

No functional change needed — just a suggestion for clarity.

.catch(() => {})
.finally(() => {
vmCreationPromises.delete(filePath);
});

return vmCreationPromise;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,22 @@ describe('buildVM and runInVM', () => {
expect(vmContext1).toBe(vmContext2);
});

test('buildVM recovers after failure for nonexistent bundle', async () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't call resetVM() before running. If serverBundlePath is already present in vmContexts from a prior test in the suite, buildVM(serverBundlePath) returns immediately via the early-exit branch (return Promise.resolve(true) on line ~192) and the hasVMContextForBundle assertion passes trivially — without actually verifying that the cleanup after failure enabled a fresh build.

Adding resetVM() at the start ensures the test exercises the actual recovery path:

test('buildVM recovers after failure for nonexistent bundle', async () => {
  resetVM(); // start with a clean slate
  const config = getConfig();
  config.supportModules = true;
  config.stubTimers = false;

  const nonExistentPath = path.resolve(__dirname, './tmp/nonexistent-bundle.js');

  // First call should fail because the bundle file does not exist
  await expect(buildVM(nonExistentPath)).rejects.toThrow();

  // After failure, the vmCreationPromises map should be cleaned up,
  // allowing a retry with the correct path to succeed
  await buildVM(serverBundlePath);
  expect(hasVMContextForBundle(serverBundlePath)).toBeTruthy();
});

const config = getConfig();
config.supportModules = true;
config.stubTimers = false;

const nonExistentPath = path.resolve(__dirname, './tmp/nonexistent-bundle.js');

// First call should fail because the bundle file does not exist
await expect(buildVM(nonExistentPath)).rejects.toThrow();

// After failure, the vmCreationPromises map should be cleaned up,
// allowing a retry with the correct path to succeed
await buildVM(serverBundlePath);
expect(hasVMContextForBundle(serverBundlePath)).toBeTruthy();
});

test('running runInVM before buildVM', async () => {
resetVM();
void prepareVM(true);
Expand Down
36 changes: 36 additions & 0 deletions react_on_rails/lib/generators/react_on_rails/base_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ def copy_packer_config
# Always ensure precompile_hook is configured (Shakapacker 9.0+ only)
# This handles all scenarios: fresh install, pre-installed Shakapacker, or user declined overwrite
configure_precompile_hook_in_shakapacker

# For SSR bundles, configure Shakapacker private_output_path (9.0+ only)
# This keeps Shakapacker and React on Rails server bundle paths in sync.
configure_private_output_path_in_shakapacker
end

def add_base_gems_to_gemfile
Expand Down Expand Up @@ -362,6 +366,38 @@ def configure_precompile_hook_in_shakapacker

puts Rainbow("✅ Configured precompile_hook in shakapacker.yml").green
end

def configure_private_output_path_in_shakapacker
# private_output_path is only supported in Shakapacker 9.0+
return unless ReactOnRails::PackerUtils.shakapacker_version_requirement_met?("9.0.0")

shakapacker_config_path = "config/shakapacker.yml"
return unless File.exist?(shakapacker_config_path)

content = File.read(shakapacker_config_path)

# Already configured? Do nothing.
return if content.match?(/^\s*private_output_path:\s*\S+/)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The \S+ quantifier requires at least one non-whitespace character after the colon, so this guard won't fire for private_output_path: (empty) or private_output_path: ~ (YAML null). A user who previously added the key with a null value would get a duplicate key inserted by the fallback branch, producing ambiguous YAML.

Suggest broadening the guard to cover any existing key (regardless of value):

return if content.match?(/^\s*private_output_path:/)

If you need to distinguish "set to a real value" from "set to null / empty", handle that case explicitly rather than silently re-inserting.


# First try: uncomment an existing private_output_path placeholder line.
updated_content = content.sub(
/^(\s*)#\s*private_output_path:\s*.*$/,
"\\1private_output_path: ssr-generated"
)

# Fallback: insert directly after public_output_path in the default section.
if updated_content == content
updated_content = content.sub(
/^(\s*)public_output_path:\s*.*\n/,
"\\0\\1private_output_path: ssr-generated\n"
)
end

return if updated_content == content

File.write(shakapacker_config_path, updated_content)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using File.write here bypasses Thor's generator DSL. configure_precompile_hook_in_shakapacker (line 363) and configure_rspack_in_shakapacker (line 333) both use gsub_file for their writes, which respects --pretend (dry-run), --force, and logs the change in generator output.

Consider restructuring as two separate gsub_file / insert_into_file calls:

# Step 1: try to uncomment the placeholder line
gsub_file shakapacker_config_path,
          /^(\s*)#\s*private_output_path:\s*.*$/,
          "\\1private_output_path: ssr-generated"

# Step 2: if the placeholder wasn't there, insert after public_output_path
unless File.read(shakapacker_config_path).match?(/^\s*private_output_path: ssr-generated/)
  insert_into_file shakapacker_config_path,
                   "\\1private_output_path: ssr-generated\n",
                   after: /^(\s*)public_output_path:.*\n/
end

This keeps the logic consistent with the rest of the generator.

puts Rainbow("✅ Configured private_output_path in shakapacker.yml").green
end
end
end
end
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Procfile for development
# You can run these commands in separate shells
rails: bundle exec rails s -p 3000
rails: bundle exec rails s -p ${PORT:-3000}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Procfile PORT default never used via process managers

Medium Severity

The ${PORT:-3000} default is effectively dead code when running through bin/dev. Both foreman and overmind assign their own PORT to each child process (defaulting to base 5000), so the :-3000 fallback is never reached. This changes the default Rails port from 3000 to 5000. The bin/dev Ruby script doesn't set ENV["PORT"] before invoking the process manager (unlike the standard Rails 7.1+ bin/dev pattern of export PORT="${PORT:-3000}"), so all downstream references to port 3000 — docs step 7, procfile_port, and print_server_info — become incorrect.

Additional Locations (2)

Fix in Cursor Fix in Web

dev-server: bin/shakapacker-dev-server
server-bundle: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Uses production-optimized, precompiled assets with development environment
# Uncomment additional processes as needed for your app

rails: bundle exec rails s -p 3001
rails: bundle exec rails s -p ${PORT:-3001}
# sidekiq: bundle exec sidekiq -C config/sidekiq.yml
# redis: redis-server
# mailcatcher: mailcatcher --foreground
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
web: bin/rails server -p 3000
web: bin/rails server -p ${PORT:-3000}
js: bin/shakapacker --watch
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@
source_entry_path: packs
public_root_path: public
public_output_path: packs
# private_output_path: ssr-generated
cache_path: tmp/shakapacker
webpack_compile_output: true
shakapacker_precompile: true
Expand Down Expand Up @@ -221,6 +222,17 @@
end
end

it "configures private_output_path for SSR bundles on Shakapacker 9+" do
assert_file "config/shakapacker.yml" do |content|
if ReactOnRails::PackerUtils.shakapacker_version_requirement_met?("9.0.0")
expect(content).to include("private_output_path: ssr-generated")
expect(content).not_to match(/^\s*#\s*private_output_path:/)
else
expect(content).to match(/^\s*#\s*private_output_path:/)
end
end
end

it "preserves other shakapacker.yml settings and comments" do
assert_file "config/shakapacker.yml" do |content|
# Comments should be preserved
Expand Down Expand Up @@ -341,6 +353,14 @@
end
end

it "adds private_output_path on Shakapacker 9+ when missing" do
assert_file "config/shakapacker.yml" do |content|
if ReactOnRails::PackerUtils.shakapacker_version_requirement_met?("9.0.0")
expect(content).to include("private_output_path: ssr-generated")
end
end
end

it "preserves YAML structure in shakapacker.yml" do
assert_file "config/shakapacker.yml" do |content|
# YAML anchors should be preserved
Expand Down
51 changes: 35 additions & 16 deletions react_on_rails_pro/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Check the [CHANGELOG](https://github.com/shakacode/react_on_rails/blob/master/CH

## Version Format

For the below docs, find the desired `<version>` in the CHANGELOG. Note that for pre-release versions:
For the commands below, choose versions from the CHANGELOG and replace placeholders like
`<gem_version>` and `<npm_version>`. Note that for pre-release versions:

- Gems use all periods: `16.2.0.beta.1`
- NPM packages use dashes: `16.2.0-beta.1`
Expand All @@ -22,11 +23,15 @@ The easiest way to set up React on Rails Pro is using the generator. This automa
For new React on Rails apps, use the `--pro` flag:

```bash
# Add the Pro gem to your Gemfile first
bundle add react_on_rails_pro
# Add the Pro gem first (pin exact version)
bundle add react_on_rails_pro --version="<gem_version>" --strict

# The generator requires a clean git working tree
git add .
git commit -m "Prepare app for React on Rails Pro install"

# Run the generator with --pro
rails generate react_on_rails:install --pro
bundle exec rails generate react_on_rails:install --pro
```

This creates the Pro initializer, node-renderer.js, installs npm packages, and adds the Node Renderer to Procfile.dev.
Expand All @@ -37,17 +42,31 @@ For existing React on Rails apps, use the standalone Pro generator:

```bash
# Add the Pro gem to your Gemfile
bundle add react_on_rails_pro
bundle add react_on_rails_pro --version="<gem_version>" --strict

# Run the Pro generator
rails generate react_on_rails:pro
bundle exec rails generate react_on_rails:pro
```

The standalone generator adds Pro-specific files and modifies your existing webpack configs (`serverWebpackConfig.js` and `ServerClientOrBoth.js`) to enable Pro features like `libraryTarget: 'commonjs2'` and `target = 'node'`.

## After Running the Generator

You still need to configure your license. Set the environment variable:
Run a quick validation, then configure your license.

```bash
bundle exec rails react_on_rails:doctor
bin/shakapacker
bin/dev
```

If port 3000 is already in use:

```bash
PORT=3001 bin/dev
```

Set the license environment variable:

```bash
export REACT_ON_RAILS_PRO_LICENSE="your-license-token-here"
Expand All @@ -61,10 +80,10 @@ RSC requires React on Rails Pro and React 19.0.x. To add RSC support, use `--rsc

```bash
# Fresh install with RSC
rails generate react_on_rails:install --rsc
bundle exec rails generate react_on_rails:install --rsc

# Or add RSC to existing Pro app
rails generate react_on_rails:rsc
bundle exec rails generate react_on_rails:rsc
```

The RSC generator creates `rscWebpackConfig.js`, adds `RSCWebpackPlugin` to both server and client webpack configs, configures `RSC_BUNDLE_ONLY` handling in `ServerClientOrBoth.js`, and sets up the RSC bundle watcher process. See [React Server Components](./react-server-components/tutorial.md) for more information.
Expand All @@ -86,7 +105,7 @@ Ensure your **Rails** app is using the **react_on_rails** gem, version 16.0.0 or
Add the `react_on_rails_pro` gem to your **Gemfile**:

```ruby
gem "react_on_rails_pro", "~> 16.2"
gem "react_on_rails_pro", "= <gem_version>"
```

Then run:
Expand Down Expand Up @@ -138,19 +157,19 @@ All React on Rails Pro users need to install the `react-on-rails-pro` npm packag
### Using npm:

```bash
npm install react-on-rails-pro
npm install react-on-rails-pro@<npm_version> --save-exact
```

### Using yarn:

```bash
yarn add react-on-rails-pro
yarn add react-on-rails-pro@<npm_version> --exact
```

### Using pnpm:

```bash
pnpm add react-on-rails-pro
pnpm add react-on-rails-pro@<npm_version> --save-exact
```

## Usage
Expand Down Expand Up @@ -187,21 +206,21 @@ See the [React Server Components tutorial](./react-server-components/tutorial.md
### Using npm:

```bash
npm install react-on-rails-pro-node-renderer
npm install react-on-rails-pro-node-renderer@<npm_version> --save-exact
```

### Using yarn:

```bash
yarn add react-on-rails-pro-node-renderer
yarn add react-on-rails-pro-node-renderer@<npm_version> --exact
```

### Add to package.json:

```json
{
"dependencies": {
"react-on-rails-pro-node-renderer": "^16.2.0"
"react-on-rails-pro-node-renderer": "<npm_version>"
}
}
```
Expand Down
Loading