Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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.
Comment thread
justin808 marked this conversation as resolved.
Outdated

#### 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
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. 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
49 changes: 49 additions & 0 deletions react_on_rails/lib/react_on_rails/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2694,6 +2694,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 +2724,54 @@ def check_pro_renderer_mode
checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
end

# Scan common deploy-script locations for references to the deprecated
# pre_stage_bundle_for_node_renderer rake task, so users on older Procfile/
# Dockerfile entries get a clear 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",
"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.
Outdated

def check_deprecated_renderer_cache_task
matches = RENDERER_CACHE_DEPLOY_SCRIPT_PATHS.select do |path|
next false unless File.exist?(path)
next false if File.size(path) >= RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES

File.read(path).include?(DEPRECATED_RENDERER_CACHE_TASK)
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

# Dockerfile matches mean the user is building an image, so they want the
# copy-mode default (no MODE needed). Procfile/bin scripts mean same-filesystem
# runtime staging, which needs the symlink mode.
def renderer_cache_migration_suggestion(path)
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
37 changes: 37 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,43 @@ 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
around do |example|
Dir.mktmpdir do |tmpdir|
Dir.chdir(tmpdir) do
File.write(
"Procfile",
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer && bundle exec puma\n"
)
example.run
end
end
end

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 no deploy scripts reference the deprecated task" do
around do |example|
Dir.mktmpdir { |tmpdir| Dir.chdir(tmpdir) { example.run } }
end

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.
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
135 changes: 110 additions & 25 deletions react_on_rails_pro/lib/react_on_rails_pro/pre_seed_renderer_cache.rb
Original file line number Diff line number Diff line change
@@ -1,47 +1,99 @@
# frozen_string_literal: true

require "fileutils"
require "pathname"
require "react_on_rails_pro/renderer_cache_helpers"

module ReactOnRailsPro
# Pre-seeds the Node Renderer bundle cache by copying compiled server bundles
# into the renderer's expected directory structure. Designed for Docker builds
# where the bundle can be baked into the image, eliminating the 410→retry
# cold-start latency on first SSR request after deployment.
# Stages the Node Renderer bundle cache in the renderer's expected directory
# structure (`<cache>/<bundleHash>/<bundleHash>.js`), including any configured
# assets_to_copy and, when RSC support is enabled, the RSC bundle and manifests.
#
# Unlike PrepareNodeRenderBundles (which stages the same cache layout via
# symlinks for same-filesystem workflows), this class copies files so the
# cache can be baked into an image or other immutable artifact.
# Supports two modes:
#
# * `:copy` (default) — copies bundle and assets. Designed for Docker image
# builds where the cache must be baked into an immutable artifact.
# * `:symlink` — creates relative symlinks. For same-filesystem workflows
# (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).
#
# Both modes produce the same on-disk cache layout, matching the renderer's
# runtime contract. The 410→retry cold-start round-trip on first SSR request
# is eliminated when the pre-seeded bundle is present at renderer startup.
class PreSeedRendererCache
def self.call
cache_dir = resolve_cache_dir
puts "[ReactOnRailsPro] Pre-seeding renderer cache in: #{cache_dir}"
VALID_MODES = %i[copy symlink].freeze

def self.call(mode: :copy)
unless VALID_MODES.include?(mode)
raise ArgumentError, "mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
end

cache_dir = resolve_cache_dir(mode)
puts "[ReactOnRailsPro] Staging renderer cache (mode: #{mode}) in: #{cache_dir}"
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool

assets = RendererCacheHelpers.collect_assets
rsc_required_paths = RendererCacheHelpers.required_rsc_asset_paths

RendererCacheHelpers.bundle_sources(pool, "pre-seeding").each do |src_bundle_path, bundle_hash|
seed_bundle(src_bundle_path, bundle_hash, cache_dir)
RendererCacheHelpers.bundle_sources(pool, action_description(mode)).each do |src_bundle_path, bundle_hash|
bundle_dir = File.join(cache_dir, bundle_hash.to_s)
stage_bundle(src_bundle_path, bundle_dir, bundle_hash, mode)
# The Node Renderer serves manifests from whichever bundle dir it loaded,
# so both server and RSC dirs need the manifests present.
copy_assets(assets, File.join(cache_dir, bundle_hash.to_s), rsc_required_paths)
stage_assets(assets, bundle_dir, rsc_required_paths, mode)
end
end

def self.resolve_cache_dir
def self.resolve_cache_dir(mode)
enforce_cache_dir_env_var!(mode)
ReactOnRailsPro::Utils.resolve_renderer_cache_dir
end
private_class_method :resolve_cache_dir

def self.seed_bundle(src_path, bundle_hash, cache_dir)
bundle_dir = File.join(cache_dir, bundle_hash.to_s)
# In copy mode (Docker image builds), silent fallback to Rails.root/.node-renderer-bundles
# is a footgun: the renderer process may run from a different cwd and resolve its default
# cache directory to a different path (e.g., /tmp/react-on-rails-pro-node-renderer-bundles),
# causing pre-seeded bundles to land somewhere the renderer never reads. Require an
# explicit env var in non-dev/test environments.
def self.enforce_cache_dir_env_var!(mode)
return unless mode == :copy
return if ENV["RENDERER_SERVER_BUNDLE_CACHE_PATH"].present? || ENV["RENDERER_BUNDLE_PATH"].present?
Comment thread
justin808 marked this conversation as resolved.
Outdated
return if Rails.env.development? || Rails.env.test?

raise ReactOnRailsPro::Error, <<~MSG.strip
Comment thread
justin808 marked this conversation as resolved.
Pre-seeding the renderer cache in copy mode (#{Rails.env}) requires an explicit
cache directory. Set RENDERER_SERVER_BUNDLE_CACHE_PATH in your environment, e.g.
in your Dockerfile:

ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles

The Node Renderer's default cache directory resolution differs between the Ruby
and standalone Node environments, so relying on the default in production-like
deploys can cause pre-seeded bundles to land in a path the renderer never reads.

If you don't need an immutable artifact (e.g. in CI or same-filesystem deploys),
use mode: :symlink instead:

rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink
MSG
end
private_class_method :enforce_cache_dir_env_var!

def self.action_description(mode)
mode == :copy ? "pre-seeding" : "pre-staging"
end
private_class_method :action_description

def self.stage_bundle(src_path, bundle_dir, bundle_hash, mode)
dest_file = File.join(bundle_dir, "#{bundle_hash}.js")
FileUtils.mkdir_p(bundle_dir)
FileUtils.cp(src_path, dest_file)
puts "[ReactOnRailsPro] Pre-seeded renderer cache: #{dest_file}"
if mode == :copy
FileUtils.mkdir_p(bundle_dir)
FileUtils.cp(src_path, dest_file)
puts "[ReactOnRailsPro] Pre-seeded renderer cache: #{dest_file}"
else
make_relative_symlink(src_path, dest_file)
end
end
private_class_method :seed_bundle
private_class_method :stage_bundle
Comment thread
justin808 marked this conversation as resolved.
Outdated

# RSC manifests are required when RSC is enabled — a missing manifest would cause
# the renderer to fail at runtime with a hard-to-diagnose error. User-configured
Expand All @@ -50,23 +102,56 @@ def self.seed_bundle(src_path, bundle_hash, cache_dir)
# in assets_to_copy cannot trigger a false-positive "required" error. Expand
# against Rails.root to match how RendererCacheHelpers.required_rsc_asset_paths
# builds its Set.
def self.copy_assets(assets, bundle_dir, rsc_required_paths)
def self.stage_assets(assets, bundle_dir, rsc_required_paths, mode)
assets.each do |asset_path|
expanded = File.expand_path(asset_path.to_s, Rails.root)
unless File.exist?(expanded)
if rsc_required_paths.include?(expanded)
raise ReactOnRailsPro::Error, "Required RSC asset not found: #{asset_path}. " \
"Build your bundles before pre-seeding the renderer cache."
"Build your bundles before #{action_description(mode)} the renderer cache."
Comment thread
justin808 marked this conversation as resolved.
Outdated
end
warn "[ReactOnRailsPro] Asset not found #{asset_path}"
next
end

dest = File.join(bundle_dir, File.basename(expanded))
FileUtils.cp(expanded, dest)
puts "[ReactOnRailsPro] Copied asset: #{dest}"
if mode == :copy
FileUtils.cp(expanded, dest)
puts "[ReactOnRailsPro] Copied asset: #{dest}"
else
make_relative_symlink(expanded, dest)
end
end
end
private_class_method :copy_assets
private_class_method :stage_assets

def self.make_relative_symlink(source, destination)
destination_dir = Pathname.new(destination).dirname
FileUtils.mkdir_p(destination_dir)
Comment thread
justin808 marked this conversation as resolved.
Outdated
FileUtils.rm_f(destination)

# Canonicalize both sides so paths like /var -> /private/var do not
# produce broken relative symlinks when the cache dir comes from tmpdir.
# Pathname#realpath raises Errno::ENOENT on a dangling symlink or a
# path that vanished between File.exist? and here (e.g. webpack output
# rotating mid-stage). Surface that as a clear ReactOnRailsPro::Error
# rather than a raw system error.
source_path =
begin
Pathname.new(source).realpath
rescue Errno::ENOENT
raise ReactOnRailsPro::Error,
"Cannot resolve real path for #{source} — it does not exist or is a dangling symlink."
end
relative_source_path = source_path.relative_path_from(destination_dir.realpath)
Comment thread
justin808 marked this conversation as resolved.
Outdated
File.symlink(relative_source_path, destination)
Comment thread
justin808 marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
puts "[ReactOnRailsPro] Symlinked #{relative_source_path} to #{destination}"
rescue Errno::ENOENT => e
raise ReactOnRailsPro::Error,
"Could not resolve real path for symlink source #{source} " \
"(#{e.message}). The file may have been removed or may be a dangling symlink. " \
"Rebuild your bundles before staging the renderer cache."
end
Comment thread
justin808 marked this conversation as resolved.
Outdated
private_class_method :make_relative_symlink
end
end
Loading
Loading