Skip to content

Commit 2f13867

Browse files
justin808claude
andcommitted
refactor: unify renderer cache staging under PreSeedRendererCache#call(mode:)
Collapse the copy-based PreSeedRendererCache and the symlink-based PrepareNodeRenderBundles into a single entry point with a `mode:` keyword argument. Both modes produce the identical <cache>/<hash>/<hash>.js layout; only the file operation differs (FileUtils.cp vs relative symlink). Changes: - ReactOnRailsPro::PreSeedRendererCache.call now accepts mode: :copy (default, for Docker/image builds) or mode: :symlink (same-filesystem workflows). Rejects unknown modes with ArgumentError. - 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. The Node renderer's default lookup can differ from the Ruby side (falling back to /tmp when cwd is outside the app tree), so silent fallback is unsafe for production-like deploys and was a silent-misconfig footgun. - `react_on_rails_pro:pre_seed_renderer_cache` rake task accepts MODE=copy (default) or MODE=symlink. - ReactOnRailsPro::PrepareNodeRenderBundles is now a thin deprecated shim that emits a once-per-process warning and delegates to PreSeedRendererCache.call(mode: :symlink). Public class and the `pre_stage_bundle_for_node_renderer` rake task remain for backward compatibility. - AssetsPrecompile.call invokes the unified API with mode: :symlink after precompile (no behavior change for existing users). - react_on_rails:doctor scans common deploy-script locations (Procfile*, Dockerfile, bin/*) for references to the deprecated pre_stage_bundle_for_node_renderer task and surfaces a migration warning. - Docs: docs/pro/node-renderer.md describes the unified API, both modes, the non-dev env-var requirement for copy mode, and the deprecation story. - CHANGELOG: new Changed entry describing the unification. - Tests: mode validation, raise-in-non-dev guard (including symlink opt-out), deprecation warning on the shim, and a doctor check test. Context: stacked on PR #3124 (jg/3122-preseed-renderer-cache). Refs: #3122 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27321bd commit 2f13867

10 files changed

Lines changed: 263 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
3131
#### Changed
3232

3333
- **[Pro]** **Pro generator now creates the Node Renderer at `renderer/node-renderer.js`**: The canonical location for the Node Renderer entry point is now a dedicated top-level `renderer/` directory instead of `client/`, making it straightforward to exclude from production Docker builds that strip JS sources after bundling. Docs and Pro `spec/dummy` now use the new path consistently. Existing apps are unaffected — the generator skips files that already exist (including a legacy `client/node-renderer.js`). Fixes [Issue 3073](https://github.com/shakacode/react_on_rails/issues/3073). [PR 3165](https://github.com/shakacode/react_on_rails/pull/3165) by [justin808](https://github.com/justin808).
34+
- **[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.
3435

3536
#### Fixed
3637

docs/pro/node-renderer.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,26 +119,35 @@ When a new container starts, the Node Renderer has an empty bundle cache. The fi
119119

120120
### Pre-seeding the bundle cache
121121

122-
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:
122+
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.
123+
124+
It supports two modes, both producing the same on-disk cache layout (`<cache>/<bundleHash>/<bundleHash>.js`):
125+
126+
- **`MODE=copy`** (default) — copies files. Use in Docker/image builds so the cache is baked into an immutable artifact.
127+
- **`MODE=symlink`** — creates relative symlinks. For same-filesystem workflows (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).
123128

124129
```dockerfile
125-
# After webpack/assets build step
130+
# After webpack/assets build step (Docker image build)
131+
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
126132
RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache
127133
```
128134

129-
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.
135+
Both modes stage the server bundle, any configured `assets_to_copy`, and (when RSC is enabled) the RSC bundle and its companion manifests.
130136

131-
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.
137+
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.
138+
139+
> [!NOTE]
140+
> 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.
132141
133142
### Configuration
134143

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

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

141-
Set `RENDERER_SERVER_BUNDLE_CACHE_PATH` in your Dockerfile to match the renderer's configuration:
150+
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:
142151

143152
```dockerfile
144153
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles

react_on_rails/lib/react_on_rails/doctor.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,7 @@ def check_pro_setup
27172717
ensure_rails_environment_loaded
27182718
check_pro_renderer_mode
27192719
check_base_package_imports
2720+
check_deprecated_renderer_cache_task
27202721
end
27212722

27222723
def check_pro_initializer_existence
@@ -2746,6 +2747,42 @@ def check_pro_renderer_mode
27462747
checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
27472748
end
27482749

2750+
# Scan common deploy-script locations for references to the deprecated
2751+
# pre_stage_bundle_for_node_renderer rake task, so users on older Procfile/
2752+
# Dockerfile entries get a clear migration nudge before the task is removed.
2753+
DEPRECATED_RENDERER_CACHE_TASK = "pre_stage_bundle_for_node_renderer"
2754+
RENDERER_CACHE_DEPLOY_SCRIPT_PATHS = [
2755+
"Procfile",
2756+
"Procfile.dev",
2757+
"Procfile.dev-static-assets",
2758+
"Procfile.production",
2759+
"Dockerfile",
2760+
"bin/deploy",
2761+
"bin/release",
2762+
"bin/docker-entrypoint"
2763+
].freeze
2764+
2765+
def check_deprecated_renderer_cache_task
2766+
matches = RENDERER_CACHE_DEPLOY_SCRIPT_PATHS.select do |path|
2767+
File.exist?(path) && File.read(path).include?(DEPRECATED_RENDERER_CACHE_TASK)
2768+
end
2769+
2770+
return if matches.empty?
2771+
2772+
checker.add_warning(<<~MSG.strip)
2773+
⚠️ Deprecated rake task '#{DEPRECATED_RENDERER_CACHE_TASK}' referenced in:
2774+
#{matches.map { |p| " • #{p}" }.join("\n")}
2775+
2776+
Replace with:
2777+
rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink
2778+
2779+
The unified 'pre_seed_renderer_cache' task uses MODE=copy by default (for
2780+
Docker/image builds) and MODE=symlink for same-filesystem workflows.
2781+
MSG
2782+
rescue StandardError => e
2783+
checker.add_warning("⚠️ Could not scan for deprecated renderer-cache task references: #{e.message}")
2784+
end
2785+
27492786
# The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
27502787
# so `import ... from 'react-on-rails'` resolves silently — loading the base package instead
27512788
# of Pro. Components registered through the base package won't have Pro features (streaming,

react_on_rails/spec/lib/react_on_rails/doctor_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,6 +2468,43 @@ class << self
24682468
end
24692469
end
24702470

2471+
describe "check_deprecated_renderer_cache_task" do
2472+
let(:doctor) { described_class.new(verbose: false, fix: false) }
2473+
let(:checker) { doctor.instance_variable_get(:@checker) }
2474+
2475+
context "when a Procfile references the deprecated task" do
2476+
around do |example|
2477+
Dir.mktmpdir do |tmpdir|
2478+
Dir.chdir(tmpdir) do
2479+
File.write(
2480+
"Procfile",
2481+
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer && bundle exec puma\n"
2482+
)
2483+
example.run
2484+
end
2485+
end
2486+
end
2487+
2488+
it "warns with migration guidance" do
2489+
doctor.send(:check_deprecated_renderer_cache_task)
2490+
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
2491+
expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true)
2492+
expect(warning_msgs.any? { |m| m[:content].include?("MODE=symlink") }).to be(true)
2493+
end
2494+
end
2495+
2496+
context "when no deploy scripts reference the deprecated task" do
2497+
around do |example|
2498+
Dir.mktmpdir { |tmpdir| Dir.chdir(tmpdir) { example.run } }
2499+
end
2500+
2501+
it "adds no warnings" do
2502+
doctor.send(:check_deprecated_renderer_cache_task)
2503+
expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty
2504+
end
2505+
end
2506+
end
2507+
24712508
describe "check_base_package_imports" do
24722509
let(:doctor) { described_class.new(verbose: false, fix: false) }
24732510
let(:checker) { doctor.instance_variable_get(:@checker) }

react_on_rails_pro/lib/react_on_rails_pro/assets_precompile.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ def build_bundles
6363
def self.call
6464
instance.build_or_fetch_bundles
6565

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

6972
def build_or_fetch_bundles
Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,94 @@
11
# frozen_string_literal: true
22

33
require "fileutils"
4+
require "pathname"
45
require "react_on_rails_pro/renderer_cache_helpers"
56

67
module ReactOnRailsPro
7-
# Pre-seeds the Node Renderer bundle cache by copying compiled server bundles
8-
# into the renderer's expected directory structure. Designed for Docker builds
9-
# where the bundle can be baked into the image, eliminating the 410→retry
10-
# cold-start latency on first SSR request after deployment.
8+
# Stages the Node Renderer bundle cache in the renderer's expected directory
9+
# structure (`<cache>/<bundleHash>/<bundleHash>.js`), including any configured
10+
# assets_to_copy and, when RSC support is enabled, the RSC bundle and manifests.
1111
#
12-
# Unlike PrepareNodeRenderBundles (which stages the same cache layout via
13-
# symlinks for same-filesystem workflows), this class copies files so the
14-
# cache can be baked into an image or other immutable artifact.
12+
# Supports two modes:
13+
#
14+
# * `:copy` (default) — copies bundle and assets. Designed for Docker image
15+
# builds where the cache must be baked into an immutable artifact.
16+
# * `:symlink` — creates relative symlinks. For same-filesystem workflows
17+
# (local dev, CI, Heroku-style same-dyno deploys, bundle-caching restores).
18+
#
19+
# Both modes produce the same on-disk cache layout, matching the renderer's
20+
# runtime contract. The 410→retry cold-start round-trip on first SSR request
21+
# is eliminated when the pre-seeded bundle is present at renderer startup.
1522
class PreSeedRendererCache
16-
def self.call
17-
cache_dir = resolve_cache_dir
18-
puts "[ReactOnRailsPro] Pre-seeding renderer cache in: #{cache_dir}"
23+
VALID_MODES = %i[copy symlink].freeze
24+
25+
def self.call(mode: :copy)
26+
unless VALID_MODES.include?(mode)
27+
raise ArgumentError, "mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
28+
end
29+
30+
cache_dir = resolve_cache_dir(mode)
31+
puts "[ReactOnRailsPro] Staging renderer cache (mode: #{mode}) in: #{cache_dir}"
1932
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
2033

2134
assets = RendererCacheHelpers.collect_assets
2235
rsc_required_paths = RendererCacheHelpers.required_rsc_asset_paths
2336

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

32-
def self.resolve_cache_dir
46+
def self.resolve_cache_dir(mode)
47+
enforce_cache_dir_env_var!(mode)
3348
ReactOnRailsPro::Utils.resolve_renderer_cache_dir
3449
end
3550
private_class_method :resolve_cache_dir
3651

37-
def self.seed_bundle(src_path, bundle_hash, cache_dir)
38-
bundle_dir = File.join(cache_dir, bundle_hash.to_s)
52+
# In copy mode (Docker image builds), silent fallback to Rails.root/.node-renderer-bundles
53+
# is a footgun: the renderer process may run from a different cwd and resolve its default
54+
# cache directory to a different path (e.g., /tmp/react-on-rails-pro-node-renderer-bundles),
55+
# causing pre-seeded bundles to land somewhere the renderer never reads. Require an
56+
# explicit env var in non-dev/test environments.
57+
def self.enforce_cache_dir_env_var!(mode)
58+
return unless mode == :copy
59+
return if ENV["RENDERER_SERVER_BUNDLE_CACHE_PATH"].present? || ENV["RENDERER_BUNDLE_PATH"].present?
60+
return if Rails.env.development? || Rails.env.test?
61+
62+
raise ReactOnRailsPro::Error, <<~MSG.strip
63+
Pre-seeding the renderer cache in copy mode (#{Rails.env}) requires an explicit
64+
cache directory. Set RENDERER_SERVER_BUNDLE_CACHE_PATH in your environment, e.g.
65+
in your Dockerfile:
66+
67+
ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles
68+
69+
The Node Renderer's default cache directory resolution differs between the Ruby
70+
and standalone Node environments, so relying on the default in production-like
71+
deploys can cause pre-seeded bundles to land in a path the renderer never reads.
72+
MSG
73+
end
74+
private_class_method :enforce_cache_dir_env_var!
75+
76+
def self.action_description(mode)
77+
mode == :copy ? "pre-seeding" : "pre-staging"
78+
end
79+
private_class_method :action_description
80+
81+
def self.stage_bundle(src_path, bundle_dir, bundle_hash, mode)
3982
dest_file = File.join(bundle_dir, "#{bundle_hash}.js")
40-
FileUtils.mkdir_p(bundle_dir)
41-
FileUtils.cp(src_path, dest_file)
42-
puts "[ReactOnRailsPro] Pre-seeded renderer cache: #{dest_file}"
83+
if mode == :copy
84+
FileUtils.mkdir_p(bundle_dir)
85+
FileUtils.cp(src_path, dest_file)
86+
puts "[ReactOnRailsPro] Pre-seeded renderer cache: #{dest_file}"
87+
else
88+
make_relative_symlink(src_path, dest_file)
89+
end
4390
end
44-
private_class_method :seed_bundle
91+
private_class_method :stage_bundle
4592

4693
# RSC manifests are required when RSC is enabled — a missing manifest would cause
4794
# the renderer to fail at runtime with a hard-to-diagnose error. User-configured
@@ -50,23 +97,41 @@ def self.seed_bundle(src_path, bundle_hash, cache_dir)
5097
# in assets_to_copy cannot trigger a false-positive "required" error. Expand
5198
# against Rails.root to match how RendererCacheHelpers.required_rsc_asset_paths
5299
# builds its Set.
53-
def self.copy_assets(assets, bundle_dir, rsc_required_paths)
100+
def self.stage_assets(assets, bundle_dir, rsc_required_paths, mode)
54101
assets.each do |asset_path|
55102
expanded = File.expand_path(asset_path.to_s, Rails.root)
56103
unless File.exist?(expanded)
57104
if rsc_required_paths.include?(expanded)
58105
raise ReactOnRailsPro::Error, "Required RSC asset not found: #{asset_path}. " \
59-
"Build your bundles before pre-seeding the renderer cache."
106+
"Build your bundles before #{action_description(mode)} the renderer cache."
60107
end
61108
warn "[ReactOnRailsPro] Asset not found #{asset_path}"
62109
next
63110
end
64111

65112
dest = File.join(bundle_dir, File.basename(expanded))
66-
FileUtils.cp(expanded, dest)
67-
puts "[ReactOnRailsPro] Copied asset: #{dest}"
113+
if mode == :copy
114+
FileUtils.cp(expanded, dest)
115+
puts "[ReactOnRailsPro] Copied asset: #{dest}"
116+
else
117+
make_relative_symlink(expanded, dest)
118+
end
68119
end
69120
end
70-
private_class_method :copy_assets
121+
private_class_method :stage_assets
122+
123+
def self.make_relative_symlink(source, destination)
124+
destination_dir = Pathname.new(destination).dirname
125+
FileUtils.mkdir_p(destination_dir)
126+
FileUtils.rm_f(destination)
127+
128+
# Canonicalize both sides so paths like /var -> /private/var do not
129+
# produce broken relative symlinks when the cache dir comes from tmpdir.
130+
source_path = Pathname.new(source).realpath
131+
relative_source_path = source_path.relative_path_from(destination_dir.realpath)
132+
File.symlink(relative_source_path, destination)
133+
puts "[ReactOnRailsPro] Symlinked #{relative_source_path} to #{destination}"
134+
end
135+
private_class_method :make_relative_symlink
71136
end
72137
end

0 commit comments

Comments
 (0)