Skip to content

Commit e97260d

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 3a4bcc5 commit e97260d

10 files changed

Lines changed: 266 additions & 96 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
2828

2929
- **[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).
3030

31+
#### Changed
32+
33+
- **[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.
34+
3135
#### Fixed
3236

3337
- **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).

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
@@ -2694,6 +2694,7 @@ def check_pro_setup
26942694
ensure_rails_environment_loaded
26952695
check_pro_renderer_mode
26962696
check_base_package_imports
2697+
check_deprecated_renderer_cache_task
26972698
end
26982699

26992700
def check_pro_initializer_existence
@@ -2723,6 +2724,42 @@ def check_pro_renderer_mode
27232724
checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
27242725
end
27252726

2727+
# Scan common deploy-script locations for references to the deprecated
2728+
# pre_stage_bundle_for_node_renderer rake task, so users on older Procfile/
2729+
# Dockerfile entries get a clear migration nudge before the task is removed.
2730+
DEPRECATED_RENDERER_CACHE_TASK = "pre_stage_bundle_for_node_renderer"
2731+
RENDERER_CACHE_DEPLOY_SCRIPT_PATHS = [
2732+
"Procfile",
2733+
"Procfile.dev",
2734+
"Procfile.dev-static-assets",
2735+
"Procfile.production",
2736+
"Dockerfile",
2737+
"bin/deploy",
2738+
"bin/release",
2739+
"bin/docker-entrypoint"
2740+
].freeze
2741+
2742+
def check_deprecated_renderer_cache_task
2743+
matches = RENDERER_CACHE_DEPLOY_SCRIPT_PATHS.select do |path|
2744+
File.exist?(path) && File.read(path).include?(DEPRECATED_RENDERER_CACHE_TASK)
2745+
end
2746+
2747+
return if matches.empty?
2748+
2749+
checker.add_warning(<<~MSG.strip)
2750+
⚠️ Deprecated rake task '#{DEPRECATED_RENDERER_CACHE_TASK}' referenced in:
2751+
#{matches.map { |p| " • #{p}" }.join("\n")}
2752+
2753+
Replace with:
2754+
rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink
2755+
2756+
The unified 'pre_seed_renderer_cache' task uses MODE=copy by default (for
2757+
Docker/image builds) and MODE=symlink for same-filesystem workflows.
2758+
MSG
2759+
rescue StandardError => e
2760+
checker.add_warning("⚠️ Could not scan for deprecated renderer-cache task references: #{e.message}")
2761+
end
2762+
27262763
# The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
27272764
# so `import ... from 'react-on-rails'` resolves silently — loading the base package instead
27282765
# 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
@@ -2396,6 +2396,43 @@ class << self
23962396
end
23972397
end
23982398

2399+
describe "check_deprecated_renderer_cache_task" do
2400+
let(:doctor) { described_class.new(verbose: false, fix: false) }
2401+
let(:checker) { doctor.instance_variable_get(:@checker) }
2402+
2403+
context "when a Procfile references the deprecated task" do
2404+
around do |example|
2405+
Dir.mktmpdir do |tmpdir|
2406+
Dir.chdir(tmpdir) do
2407+
File.write(
2408+
"Procfile",
2409+
"web: bundle exec rake react_on_rails_pro:pre_stage_bundle_for_node_renderer && bundle exec puma\n"
2410+
)
2411+
example.run
2412+
end
2413+
end
2414+
end
2415+
2416+
it "warns with migration guidance" do
2417+
doctor.send(:check_deprecated_renderer_cache_task)
2418+
warning_msgs = checker.messages.select { |m| m[:type] == :warning }
2419+
expect(warning_msgs.any? { |m| m[:content].include?("pre_stage_bundle_for_node_renderer") }).to be(true)
2420+
expect(warning_msgs.any? { |m| m[:content].include?("MODE=symlink") }).to be(true)
2421+
end
2422+
end
2423+
2424+
context "when no deploy scripts reference the deprecated task" do
2425+
around do |example|
2426+
Dir.mktmpdir { |tmpdir| Dir.chdir(tmpdir) { example.run } }
2427+
end
2428+
2429+
it "adds no warnings" do
2430+
doctor.send(:check_deprecated_renderer_cache_task)
2431+
expect(checker.messages.select { |m| m[:type] == :warning }).to be_empty
2432+
end
2433+
end
2434+
end
2435+
23992436
describe "check_base_package_imports" do
24002437
let(:doctor) { described_class.new(verbose: false, fix: false) }
24012438
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)