Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ exclude = [
'^https://(www\.)?estately\.com', # Returns 403
'^https://hiring\.careerbuilder\.com', # Returns 403
'^https://(www\.)?yourmechanic\.com', # Returns 403/503
'^https://(www\.)?guavapass\.com', # TLS handshake failures from CI
'^https?://(www\.)?hvmn\.com', # Returns 503 from CI
'^https://(www\.)?hawaiichee\.com/?$', # Intermittent 500 from CI

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

- **[Pro]** **Pre-seed renderer cache for Docker builds**: New `react_on_rails_pro:pre_seed_renderer_cache` rake task copies compiled server bundles into the Node Renderer's bundle-hash cache directory structure during Docker image builds, eliminating the 410→retry cold-start latency (200ms–1s+) on the first SSR request after deployment. Supports `RENDERER_SERVER_BUNDLE_CACHE_PATH`, RSC bundles, and rolling-deploy guidance centered on current and previous bundle hashes. The legacy `pre_stage_bundle_for_node_renderer` task now stages the same cache layout via symlinks for same-filesystem workflows. **Note:** `RENDERER_BUNDLE_PATH` is now deprecated in favor of `RENDERER_SERVER_BUNDLE_CACHE_PATH` across both tasks. Existing users with `RENDERER_BUNDLE_PATH` set will see a deprecation warning on stderr. [PR 3124](https://github.com/shakacode/react_on_rails/pull/3124) by [justin808](https://github.com/justin808).

#### Changed

- **[Pro]** **Unified renderer cache staging**: `ReactOnRailsPro::PreSeedRendererCache.call(mode: :copy | :symlink)` is now the single entry point for staging the Node Renderer cache. Both modes produce the same `<cache>/<bundleHash>/<bundleHash>.js` layout. The `react_on_rails_pro:pre_seed_renderer_cache` rake task accepts `MODE=copy` (default; Docker/image builds) or `MODE=symlink` (same-filesystem). `MODE=copy` now raises a clear error when neither `RENDERER_SERVER_BUNDLE_CACHE_PATH` nor `RENDERER_BUNDLE_PATH` is set in non-dev/test environments, because the Node renderer's default lookup can differ from the Ruby side and would silently drop pre-seeded bundles in the wrong directory. The legacy `react_on_rails_pro:pre_stage_bundle_for_node_renderer` task and `ReactOnRailsPro::PrepareNodeRenderBundles` class remain as deprecated shims that emit a once-per-process warning and delegate to `mode: :symlink`. `react_on_rails:doctor` flags deploy scripts that still reference the deprecated task. [PR 3167](https://github.com/shakacode/react_on_rails/pull/3167) by [justin808](https://github.com/justin808).

#### Fixed

- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
3 changes: 2 additions & 1 deletion docs/oss/building-features/node-renderer/js-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ Deprecated options:

### Testing example:

[spec/dummy/client/node-renderer.js](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails_pro/spec/dummy/client/node-renderer.js)
The repository's dummy app keeps a full integration-test launcher at
`react_on_rails_pro/spec/dummy/client/node-renderer.js`.

### Simple example:

Expand Down
21 changes: 15 additions & 6 deletions docs/pro/node-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,26 +119,35 @@ When a new container starts, the Node Renderer has an empty bundle cache. The fi

### Pre-seeding the bundle cache

The `pre_seed_renderer_cache` rake task copies compiled server bundles directly into the renderer's cache directory during your Docker build, so the renderer finds them immediately on startup:
The `pre_seed_renderer_cache` rake task stages compiled server bundles directly into the renderer's cache directory, so the renderer finds them immediately on startup.

It supports two modes, both producing the same on-disk cache layout (`<cache>/<bundleHash>/<bundleHash>.js`):

- **`MODE=copy`** (default) — copies files. Use in Docker/image builds so the cache is baked into an immutable artifact.
- **`MODE=symlink`** — creates relative symlinks. For same-filesystem workflows (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).

```dockerfile
# After webpack/assets build step
# After webpack/assets build step (Docker image build)
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache
```

This copies the bundle into the renderer's expected directory structure (`<cache>/<bundleHash>/<bundleHash>.js`), including any configured `assets_to_copy` and RSC bundles when RSC support is enabled.
Both modes stage the server bundle, any configured `assets_to_copy`, and (when RSC is enabled) the RSC bundle and its companion manifests.

This is the preferred path for Docker and other image-build workflows. React on Rails Pro has long supported runtime bundle uploads and the older `react_on_rails_pro:pre_stage_bundle_for_node_renderer` task for same-filesystem deployments; `pre_seed_renderer_cache` is the copy-based variant that fits immutable artifacts while using the same bundle-hash cache layout.
The `pre_seed_renderer_cache` task is also invoked automatically at the end of `assets:precompile` with `MODE=symlink`, so the local/CI/Heroku path has zero new configuration.

> [!NOTE]
> The older `react_on_rails_pro:pre_stage_bundle_for_node_renderer` rake task and `ReactOnRailsPro::PrepareNodeRenderBundles` class are deprecated in favor of the unified API. Both remain available as thin shims that emit a deprecation warning and delegate to `MODE=symlink`. `react_on_rails:doctor` flags deploy scripts that still reference the deprecated task.

### Configuration

The task follows the same environment-variable precedence as the Node Renderer, while the default fallback can differ between Ruby and standalone Node environments:

1. `RENDERER_SERVER_BUNDLE_CACHE_PATH` environment variable (preferred)
2. `RENDERER_BUNDLE_PATH` environment variable (deprecated — emits a warning)
3. `Rails.root.join(".node-renderer-bundles")` (Rails-side default when env vars are unset)
3. `Rails.root.join(".node-renderer-bundles")` (Rails-side default when env vars are unset, only accepted for `MODE=symlink` and in dev/test)

Set `RENDERER_SERVER_BUNDLE_CACHE_PATH` in your Dockerfile to match the renderer's configuration:
In **`MODE=copy`** (Docker image builds) the task requires one of the env vars above to be set in non-dev/test environments. "Non-dev/test" means any `RAILS_ENV` other than `development` or `test` — including custom environments like `staging`, `review`, or `ci` — so set `RENDERER_SERVER_BUNDLE_CACHE_PATH` wherever you run `MODE=copy` outside of local/CI-test runs. Because the Node renderer's own default can differ (e.g., falling back to `/tmp/react-on-rails-pro-node-renderer-bundles` when its `cwd` sits outside the app tree), relying on the silent fallback risks pre-seeded bundles landing in a directory the renderer never reads. The task raises a clear error if the env var is missing:

```dockerfile
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
Expand Down
64 changes: 64 additions & 0 deletions react_on_rails/lib/react_on_rails/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ class Doctor
DEFAULT_SHAKAPACKER_CONFIG_PATH = "config/shakapacker.yml"
SERVER_BUNDLE_SOURCE_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs].freeze

# Deprecated-renderer-cache scan (used by check_deprecated_renderer_cache_task):
# look for references to the old pre_stage_bundle_for_node_renderer task in
# common deploy-script locations so users on older Procfile/Dockerfile entries
# get a migration nudge before the task is removed.
DEPRECATED_RENDERER_CACHE_TASK = "pre_stage_bundle_for_node_renderer"
RENDERER_CACHE_DEPLOY_SCRIPT_PATHS = [
"Procfile",
"Procfile.dev",
"Procfile.dev-static-assets",
"Procfile.production",
"Dockerfile",
"Dockerfile.production",
"Dockerfile.staging",
"Dockerfile.review",
"bin/deploy",
"bin/release",
"bin/docker-entrypoint"
].freeze
RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES = 1_048_576
Comment thread
justin808 marked this conversation as resolved.

def initialize(verbose: false, fix: false)
@verbose = verbose
@fix = fix
Expand Down Expand Up @@ -2694,6 +2714,7 @@ def check_pro_setup
ensure_rails_environment_loaded
check_pro_renderer_mode
check_base_package_imports
check_deprecated_renderer_cache_task
end

def check_pro_initializer_existence
Expand Down Expand Up @@ -2723,6 +2744,49 @@ def check_pro_renderer_mode
checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
end

def check_deprecated_renderer_cache_task
# Resolve against Rails.root (not Dir.pwd) so the scan still fires when
# doctor is invoked from a subdirectory — otherwise the checks silently
# find nothing and the deprecation warning never surfaces.
#
# Substring match is intentional: a comment line in a Procfile/Dockerfile
# that mentions the old task name will also trigger the warning. That is
# acceptable — the worst case is a benign migration nudge on a file that's
# already been migrated but still references the old name in a comment.
matches = RENDERER_CACHE_DEPLOY_SCRIPT_PATHS.select do |path|
full_path = Rails.root.join(path)
next false unless full_path.exist?
# Skip files larger than 1 MB; deploy scripts should be tiny.
next false if full_path.size > RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES

full_path.read.include?(DEPRECATED_RENDERER_CACHE_TASK)
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
Outdated
end

return if matches.empty?

checker.add_warning(<<~MSG.strip)
Comment thread
justin808 marked this conversation as resolved.
⚠️ Deprecated rake task '#{DEPRECATED_RENDERER_CACHE_TASK}' referenced in:
#{matches.map { |p| " • #{p} → #{renderer_cache_migration_suggestion(p)}" }.join("\n")}

The unified 'pre_seed_renderer_cache' task uses MODE=copy by default (for
Docker/image builds) and MODE=symlink for same-filesystem workflows.
MSG
rescue StandardError => e
checker.add_warning("⚠️ Could not scan for deprecated renderer-cache task references: #{e.message}")
end

def renderer_cache_migration_suggestion(path)
# Dockerfile* entries are RUN steps during image build, so copy mode bakes
# the cache into the layer. Procfile, bin/*, and other runtime scripts run
# inside the already-booted container or dyno, where both the app and
# renderer share the same filesystem, so symlink mode is correct.
if path.start_with?("Dockerfile")
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
"rake react_on_rails_pro:pre_seed_renderer_cache"
else
"rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink"
end
end

# The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
# so `import ... from 'react-on-rails'` resolves silently — loading the base package instead
# of Pro. Components registered through the base package won't have Pro features (streaming,
Expand Down
140 changes: 140 additions & 0 deletions react_on_rails/spec/lib/react_on_rails/doctor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2396,6 +2396,146 @@ class << self
end
end

describe "check_deprecated_renderer_cache_task" do
let(:doctor) { described_class.new(verbose: false, fix: false) }
let(:checker) { doctor.instance_variable_get(:@checker) }

context "when a Procfile references the deprecated task" do
let(:tmpdir) { Dir.mktmpdir }

before do
File.write(
File.join(tmpdir, "Procfile"),
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer && bundle exec puma\n"
)
allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir))
end

after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "warns with migration guidance" do
doctor.send(:check_deprecated_renderer_cache_task)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true)
expect(warning_msgs.any? { |m| m[:content].include?("MODE=symlink") }).to be(true)
end
end

context "when a Dockerfile variant references the deprecated task" do
let(:tmpdir) { Dir.mktmpdir }

before do
File.write(
File.join(tmpdir, "Dockerfile.production"),
"RUN bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n"
)
allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir))
end

after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "suggests the copy-mode task without MODE=symlink" do
doctor.send(:check_deprecated_renderer_cache_task)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs).not_to be_empty
suggestion_line = warning_msgs
.flat_map { |m| m[:content].split("\n") }
.find { |line| line.include?("Dockerfile.production →") }
expect(suggestion_line).not_to be_nil
expect(suggestion_line).to include("pre_seed_renderer_cache")
expect(suggestion_line).not_to include("MODE=symlink")
end
end

context "when no deploy scripts reference the deprecated task" do
let(:tmpdir) { Dir.mktmpdir }

before { allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir)) }
after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "adds no warnings" do
doctor.send(:check_deprecated_renderer_cache_task)
expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty
end
end
Comment thread
justin808 marked this conversation as resolved.

context "when a deploy-script file exceeds the size gate" do
let(:tmpdir) { Dir.mktmpdir }

before do
# Stub the cap so we do not have to write a real 1 MB file — the gate
# logic is what we are exercising, not the specific threshold.
stub_const("ReactOnRails::Doctor::RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES", 64)
padding = "x" * 128
File.write(
File.join(tmpdir, "Procfile"),
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n#{padding}"
)
allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir))
end

after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "silently skips the file and emits no warning" do
doctor.send(:check_deprecated_renderer_cache_task)
expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty
end
end

context "when a deploy-script file is exactly the size gate" do
let(:tmpdir) { Dir.mktmpdir }
let(:script_content) do
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n"
end

before do
stub_const("ReactOnRails::Doctor::RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES", script_content.bytesize)
File.write(File.join(tmpdir, "Procfile"), script_content)
allow(Rails).to receive(:root).and_return(Pathname.new(tmpdir))
end

after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "still scans the file" do
doctor.send(:check_deprecated_renderer_cache_task)
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true)
end
end

context "when reading a deploy-script file raises an unexpected error" do
let(:tmpdir) { Dir.mktmpdir }
let(:procfile_path) { File.join(tmpdir, "Procfile") }

before do
File.write(
procfile_path,
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer\n"
)
root_path = Pathname.new(tmpdir)
allow(Rails).to receive(:root).and_return(root_path)

# Simulate a filesystem error (e.g. transient EIO or a permissions race)
# on the actual Pathname receiver used by the doctor scan.
failing_procfile = instance_double(Pathname)
allow(failing_procfile).to receive_messages(exist?: true, size: File.size(procfile_path))
allow(failing_procfile).to receive(:read).and_raise(Errno::EIO, "simulated read failure")
allow(root_path).to receive(:join).and_call_original
allow(root_path).to receive(:join).with("Procfile").and_return(failing_procfile)
end

after { FileUtils.remove_entry(tmpdir) if File.directory?(tmpdir) }

it "captures the error as a warning instead of failing the doctor check" do
expect { doctor.send(:check_deprecated_renderer_cache_task) }.not_to raise_error
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
expect(warning_msgs.any? do |m|
m[:content].include?("Could not scan for deprecated renderer-cache task")
end).to be(true)
end
end
end

describe "check_base_package_imports" do
let(:doctor) { described_class.new(verbose: false, fix: false) }
let(:checker) { doctor.instance_variable_get(:@checker) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ def build_bundles
def self.call
instance.build_or_fetch_bundles

ReactOnRailsPro::PrepareNodeRenderBundles.call if ReactOnRailsPro.configuration.node_renderer?
# Auto-stage via symlink after asset precompile (same-filesystem default).
# Docker/image builds should invoke `rake react_on_rails_pro:pre_seed_renderer_cache`
# (MODE=copy, the default) as a separate step.
ReactOnRailsPro::PreSeedRendererCache.call(mode: :symlink) if ReactOnRailsPro.configuration.node_renderer?
end

def build_or_fetch_bundles
Expand Down
Loading
Loading