Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
486fa2b
Fix generated demo path hints
justin808 Jun 19, 2026
c825e0f
Address generator path hint review
justin808 Jun 19, 2026
782a030
Handle root Shakapacker entry paths
justin808 Jun 19, 2026
8d1fdd7
Finalize Shakapacker config before path copies
justin808 Jun 19, 2026
f8d7f03
Address generator review cleanups
justin808 Jun 19, 2026
6ccc12a
Document generator path invariants
justin808 Jun 19, 2026
f8eb86f
Compute generated stylesheet imports
justin808 Jun 19, 2026
a6c6441
Cover root entrypoint helper path
justin808 Jun 19, 2026
b9e7d68
Keep generator helper test memoization isolated
justin808 Jun 19, 2026
9db0ddd
Cover Tailwind custom source paths
justin808 Jun 19, 2026
caa2730
Harden generator path helper tests
justin808 Jun 19, 2026
86348aa
Extract generator memoization reset helper
justin808 Jun 19, 2026
61f4c26
Guard generator path helper inputs
justin808 Jun 19, 2026
6c3b0e9
Cover RSC Tailwind custom source paths
justin808 Jun 19, 2026
cfa66d7
Cover generator path guard reviews
justin808 Jun 19, 2026
33e8f0c
Cover Redux Tailwind custom source paths
justin808 Jun 19, 2026
a406d3e
Cover generator guard review feedback
justin808 Jun 19, 2026
b71a727
Preserve bundler choice during config copy
justin808 Jun 19, 2026
b278327
Cover slash entry demo generation paths
justin808 Jun 19, 2026
756544e
Respect custom source roots for TypeScript setup
justin808 Jun 19, 2026
558296b
Create TypeScript support after Shakapacker config
justin808 Jun 19, 2026
b2a6c07
Honor skip for CSS module type generation
justin808 Jun 19, 2026
aa8edbf
Normalize RSC slash entry discovery paths
justin808 Jun 19, 2026
b52c633
Add changelog for generator path hints
justin808 Jun 20, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

- **Explicit Webpack installs now pass the resolved bundler to Shakapacker.** `rails generate react_on_rails:install --no-rspack` and `--webpack` now set `SHAKAPACKER_ASSETS_BUNDLER=webpack` before running `shakapacker:install`, so Shakapacker installs Webpack dependencies instead of falling back to its default bundler. Fixes [Issue 4108](https://github.com/shakacode/react_on_rails/issues/4108). [PR 4109](https://github.com/shakacode/react_on_rails/pull/4109) by [ihabadham](https://github.com/ihabadham).

- **Generated demo paths now honor custom Shakapacker source roots.** The install generator resolves demo components, entrypoints, stylesheets, TypeScript includes, Tailwind imports, and RSC hints from the app's Shakapacker `source_path` / `source_entry_path` settings, including slash entry roots, while wrapping long source hints in the generated demo views. Fixes [Issue 4062](https://github.com/shakacode/react_on_rails/issues/4062). [PR 4130](https://github.com/shakacode/react_on_rails/pull/4130) by [justin808](https://github.com/justin808).

- **Abort the in-flight SSR render when the client disconnects (Pro streaming)**: Previously, when an HTTP client disconnected (or a request timed out) mid-stream, the Node renderer kept driving the React render to completion against a consumer that was already gone — wasting CPU and, for RSC/`cache()`-wrapped data fetches, continuing to hit the app's database/APIs. The Pro streaming layer now propagates the consumer-side teardown upstream into ReactDOM's `PipeableStream.abort()`: when the renderer worker detects the client disconnect it destroys the render's output stream, which aborts the in-flight render and releases the request's RSC payload streams. Normal completion is unaffected (the abort only fires when the output is destroyed before it ends, and never when a render error closes the stream). This also establishes the precondition for React 19.2's [`cacheSignal`](https://react.dev/reference/react/cacheSignal), which React settles automatically once a render is aborted (the `cacheSignal`-specific test and docs remain a follow-up). Part of [Issue 3885](https://github.com/shakacode/react_on_rails/issues/3885). [PR 4093](https://github.com/shakacode/react_on_rails/pull/4093) by [justin808](https://github.com/justin808).

#### Added
Expand Down
82 changes: 46 additions & 36 deletions react_on_rails/lib/generators/react_on_rails/base_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,46 @@ def add_hello_world_route
route "get 'hello_world', to: 'hello_world#index'"
end

def copy_packer_config
Comment thread
justin808 marked this conversation as resolved.
# Rails generator actions run in method definition order.
Comment thread
justin808 marked this conversation as resolved.
# Keep this before actions that call shakapacker_source_path or
# shakapacker_source_entry_path; those helpers memoize on first read.
if instance_variable_defined?(:@shakapacker_source_path) ||
Comment thread
justin808 marked this conversation as resolved.
instance_variable_defined?(:@shakapacker_source_entry_path)
raise Thor::Error, "copy_packer_config must run before path-dependent generator actions"
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
end

base_path = "base/base/"
config = "config/shakapacker.yml"
use_rspack = using_rspack?
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.

Comment thread
justin808 marked this conversation as resolved.
if options.shakapacker_just_installed?
say "Replacing Shakapacker default config with React on Rails version"
# Shakapacker's installer just created this file from scratch (no pre-existing config).
# Safe to overwrite silently with RoR's version-aware template (e.g., private_output_path).
template("#{base_path}#{config}.tt", config, force: true)
else
say "Adding Shakapacker #{ReactOnRails::PackerUtils.shakapacker_version} config"
# Thor handles the conflict: prompts user interactively, or respects --force/--skip flags.
template("#{base_path}#{config}.tt", config)
end
Comment thread
justin808 marked this conversation as resolved.

# Configure bundler-specific settings
configure_rspack_in_shakapacker if use_rspack

# Always ensure precompile_hook is configured (Shakapacker 9.0+ only)
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 create_react_directories
# Skip HelloWorld directory for Redux (uses HelloWorldApp) or RSC (uses HelloServer)
return if options.redux? || use_rsc?

empty_directory("app/javascript/src/HelloWorld/ror_components")
empty_directory(File.join(example_component_source_directory("HelloWorld"), "ror_components"))
end

def copy_base_files
Expand Down Expand Up @@ -235,14 +270,15 @@ def copy_base_files

def copy_js_bundle_files
base_path = "base/base/"
base_files = %w[app/javascript/packs/server-bundle.js]
copy_file("#{base_path}app/javascript/packs/server-bundle.js",
shakapacker_entrypoint_path("server-bundle.js"))

# Skip HelloWorld CSS for Redux (uses HelloWorldApp) or RSC (uses HelloServer)
unless options.redux? || use_rsc? || use_tailwind?
base_files << "app/javascript/src/HelloWorld/ror_components/HelloWorld.module.css"
end
return if options.redux? || use_rsc? || use_tailwind?
Comment thread
justin808 marked this conversation as resolved.

base_files.each { |file| copy_file("#{base_path}#{file}", file) }
copy_file("#{base_path}app/javascript/src/HelloWorld/ror_components/HelloWorld.module.css",
File.join(example_component_source_directory("HelloWorld"),
"ror_components/HelloWorld.module.css"))
end

def copy_webpack_config
Expand Down Expand Up @@ -274,33 +310,7 @@ def copy_tailwind_files

base_path = "base/tailwind/"
copy_file("#{base_path}app/javascript/stylesheets/application.css",
"app/javascript/stylesheets/application.css")
end

def copy_packer_config
base_path = "base/base/"
config = "config/shakapacker.yml"

if options.shakapacker_just_installed?
say "Replacing Shakapacker default config with React on Rails version"
# Shakapacker's installer just created this file from scratch (no pre-existing config).
# Safe to overwrite silently with RoR's version-aware template (e.g., private_output_path).
template("#{base_path}#{config}.tt", config, force: true)
else
say "Adding Shakapacker #{ReactOnRails::PackerUtils.shakapacker_version} config"
# Thor handles the conflict: prompts user interactively, or respects --force/--skip flags.
template("#{base_path}#{config}.tt", config)
end

# Configure bundler-specific settings
configure_rspack_in_shakapacker if using_rspack?

# Always ensure precompile_hook is configured (Shakapacker 9.0+ only)
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
shakapacker_stylesheet_path("application.css"))
Comment thread
justin808 marked this conversation as resolved.
end

def add_base_gems_to_gemfile
Expand Down Expand Up @@ -686,10 +696,10 @@ def home_page_stack_badges
end

def example_source_path
return "app/javascript/src/HelloServer/" if use_rsc? && !options.redux?
return "app/javascript/src/HelloWorldApp/" if options.redux?
return example_component_source_path("HelloServer") if use_rsc? && !options.redux?
return example_component_source_path("HelloWorldApp") if options.redux?

"app/javascript/src/HelloWorld/"
example_component_source_path("HelloWorld")
end

def example_view_path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ def build_hello_world_view_config(component_name:, source_path:, landing_page:,
}
end

def build_hello_server_view_config(landing_page:, redux_demo:)
def build_hello_server_view_config(landing_page:, redux_demo:, source_path:)
{
title: "React Server Components Demo",
intro: "This route shows the Pro React Server Components flow: Rails streams an async server " \
"component response while only client islands ship JavaScript to the browser.",
highlights: hello_server_highlights,
file_hints: hello_server_file_hints,
file_hints: hello_server_file_hints(source_path:),
quick_links: hello_server_quick_links(landing_page:, redux_demo:),
learning_links: hello_server_learning_links
}
Expand Down Expand Up @@ -167,10 +167,10 @@ def hello_server_highlights
]
end

def hello_server_file_hints
def hello_server_file_hints(source_path:)
[
{
path: "app/javascript/src/HelloServer/",
path: source_path,
description: "Source for the generated server component example and client island."
},
{
Expand Down
107 changes: 107 additions & 0 deletions react_on_rails/lib/generators/react_on_rails/generator_helper.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# frozen_string_literal: true

require "json"
require "pathname"
require_relative "shakapacker_precompile_hook_helper"

# rubocop:disable Metrics/ModuleLength
module GeneratorHelper
include ReactOnRails::Generators::ShakapackerPrecompileHookHelper

DEFAULT_SHAKAPACKER_SOURCE_PATH = "app/javascript"
DEFAULT_SHAKAPACKER_SOURCE_ENTRY_PATH = "packs"
private_constant :DEFAULT_SHAKAPACKER_SOURCE_PATH, :DEFAULT_SHAKAPACKER_SOURCE_ENTRY_PATH

def package_json
# Lazy load package_json gem only when actually needed for dependency management

Expand Down Expand Up @@ -75,6 +80,108 @@ def component_extension(options)
options.typescript? ? "tsx" : "jsx"
end

def shakapacker_source_path
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
# These helpers memoize config-backed paths. Install generators must copy or
# overwrite config/shakapacker.yml before any path-dependent copy action runs.
@shakapacker_source_path ||= configured_shakapacker_relative_path("source_path", DEFAULT_SHAKAPACKER_SOURCE_PATH)
Comment thread
justin808 marked this conversation as resolved.
end

def shakapacker_source_entry_path
@shakapacker_source_entry_path ||= configured_shakapacker_relative_path(
"source_entry_path",
DEFAULT_SHAKAPACKER_SOURCE_ENTRY_PATH,
allow_root: true
)
end

def shakapacker_entrypoint_path(filename)
filename = filename.to_s
raise ArgumentError, "filename must be present" if filename.empty?

entry_dir = shakapacker_source_entry_path # "" means entrypoints live directly under source_path.
File.join(*[shakapacker_source_path, entry_dir, filename].reject(&:empty?))
Comment thread
justin808 marked this conversation as resolved.
end
Comment thread
cursor[bot] marked this conversation as resolved.

def shakapacker_stylesheet_path(filename)
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
# "stylesheets" is a generated demo convention, not a Shakapacker config key.
File.join(shakapacker_source_path, "stylesheets", filename)
end

def relative_stylesheet_import_path(entry_path, filename: "application.css")
Comment thread
justin808 marked this conversation as resolved.
# InstallGenerator copies the final Shakapacker config before path-dependent demo files are generated.
safe_entry_path = safe_generator_destination_path(entry_path, default: nil)
raise ArgumentError, "entry_path must stay inside the generator destination" if safe_entry_path.nil?
Comment thread
justin808 marked this conversation as resolved.

entry_dir = Pathname.new(File.join(destination_root, safe_entry_path)).dirname
stylesheet = Pathname.new(File.join(destination_root, shakapacker_stylesheet_path(filename)))

stylesheet.relative_path_from(entry_dir).to_s
end
Comment thread
justin808 marked this conversation as resolved.

def example_component_source_directory(component_name)
File.join(shakapacker_source_path, "src", component_name)
end

def example_component_source_path(component_name)
Comment thread
justin808 marked this conversation as resolved.
# Trailing slash is intentional: this value is only for generated demo file hints.
"#{example_component_source_directory(component_name)}/"
end

def configured_shakapacker_relative_path(config_key, default, allow_root: false)
config_path = File.join(destination_root, "config/shakapacker.yml")
Comment thread
justin808 marked this conversation as resolved.
return default unless File.exist?(config_path)

config = parse_shakapacker_yml(config_path)
configured_path = shakapacker_path_config_value(config, config_key)

safe_generator_destination_path(configured_path, default:, allow_root:)
rescue Psych::SyntaxError
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
default
Comment thread
justin808 marked this conversation as resolved.
end
Comment thread
justin808 marked this conversation as resolved.

def shakapacker_path_config_value(config, config_key)
# Generators run in the development context, so prefer that section before falling back to shared defaults.
%w[development default].each do |section_name|
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
section = shakapacker_config_section(config, section_name)
value = shakapacker_config_value(section, config_key)
return value unless value.to_s.strip.empty?
end

nil
end

def safe_generator_destination_path(path, default:, allow_root: false)
Comment thread
justin808 marked this conversation as resolved.
candidate = path.to_s.strip
return default if candidate.empty?

pathname = Pathname.new(candidate).cleanpath
Comment thread
justin808 marked this conversation as resolved.
# Shakapacker uses "/" to mean entrypoints live directly under source_path.
return "" if allow_root && pathname.to_s == "/"
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.

relative_path = if pathname.absolute?
absolute_path_relative_to_destination(pathname)
else
pathname.to_s
end

return default if unsafe_generator_destination_path?(relative_path)
Comment thread
justin808 marked this conversation as resolved.

relative_path
rescue ArgumentError # Pathname.new raises on null bytes in path strings.
default
end

def absolute_path_relative_to_destination(pathname)
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
destination = Pathname.new(destination_root).cleanpath
pathname.relative_path_from(destination).to_s
rescue ArgumentError
nil # Signals the caller to fall back to the default path.
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
end
Comment thread
justin808 marked this conversation as resolved.

def unsafe_generator_destination_path?(path)
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
path.nil? || path == "." || path == ".." || path.start_with?("../")
end

# Check if a gem is present in Gemfile.lock
# Always checks the target app's Gemfile.lock, not inherited BUNDLE_GEMFILE
# See: https://github.com/shakacode/react_on_rails/issues/2287
Expand Down
19 changes: 9 additions & 10 deletions react_on_rails/lib/generators/react_on_rails/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,7 @@ def rspack_bundler_default

def invoke_generators
ensure_shakapacker_installed
if options.typescript?
install_typescript_dependencies
create_css_module_types
create_typescript_config
end
install_typescript_dependencies if options.typescript?
# `invoke` instantiates child generators with a fresh options hash, so
# --pretend/--force/--skip must be forwarded explicitly at each boundary.
invoke "react_on_rails:base", [],
Expand All @@ -247,6 +243,11 @@ def invoke_generators
shakapacker_just_installed: shakapacker_just_installed?,
force: options[:force], skip: options[:skip], pretend: options[:pretend] }

if options.typescript?
create_css_module_types
create_typescript_config
end
Comment thread
justin808 marked this conversation as resolved.

# Component generator logic:
# - --rsc without --redux: Skip HelloWorld, HelloServer will be generated in setup_rsc
# - --rsc with --redux: Generate HelloWorldApp (user explicitly wants Redux) + HelloServer
Expand Down Expand Up @@ -1169,9 +1170,7 @@ def create_css_module_types

say "📝 Creating CSS module type definitions...", :yellow

# Ensure the types directory exists
FileUtils.mkdir_p("app/javascript/types")

css_module_types_path = File.join(shakapacker_source_path, "types", "css-modules.d.ts")
Comment thread
justin808 marked this conversation as resolved.
css_module_types_content = <<~TS.strip
// TypeScript definitions for CSS modules
declare module "*.module.css" {
Expand All @@ -1190,7 +1189,7 @@ def create_css_module_types
}
TS

File.write("app/javascript/types/css-modules.d.ts", css_module_types_content)
create_file(css_module_types_path, css_module_types_content)
say "✅ Created CSS module type definitions", :green
end

Expand Down Expand Up @@ -1222,7 +1221,7 @@ def create_typescript_config
"jsx" => "react-jsx"
},
"include" => [
"app/javascript/**/*"
File.join(shakapacker_source_path, "**/*")
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,27 @@ def copy_base_files
base_js_path = "base/base"
tailwind_js_path = "base/tailwind"
ext = component_extension(options)
component_dir = example_component_source_directory("HelloWorld")

# Determine which component files to copy based on TypeScript option
client_component =
"app/javascript/src/HelloWorld/ror_components/HelloWorld.client.#{ext}"
"#{component_dir}/ror_components/HelloWorld.client.#{ext}"
server_component =
"app/javascript/src/HelloWorld/ror_components/HelloWorld.server.#{ext}"
"#{component_dir}/ror_components/HelloWorld.server.#{ext}"
Comment thread
justin808 marked this conversation as resolved.
client_component_template = "app/javascript/src/HelloWorld/ror_components/HelloWorld.client.#{ext}"

# Source paths are relative to this generator's templates; only
# destinations vary with the app's Shakapacker config.
if use_tailwind?
copy_file("#{tailwind_js_path}/#{client_component}", client_component)
copy_file("#{tailwind_js_path}/#{client_component_template}", client_component)
else
copy_file("#{base_js_path}/#{client_component}", client_component)
copy_file("#{base_js_path}/#{client_component_template}", client_component)
copy_file("#{base_js_path}/app/javascript/src/HelloWorld/ror_components/HelloWorld.module.css",
"app/javascript/src/HelloWorld/ror_components/HelloWorld.module.css")
"#{component_dir}/ror_components/HelloWorld.module.css")
end

copy_file("#{base_js_path}/#{server_component}", server_component)
copy_file("#{base_js_path}/app/javascript/src/HelloWorld/ror_components/HelloWorld.server.#{ext}",
Comment thread
justin808 marked this conversation as resolved.
Comment thread
justin808 marked this conversation as resolved.
server_component)
Comment thread
justin808 marked this conversation as resolved.
end

def create_appropriate_templates
Expand All @@ -58,7 +63,7 @@ def create_appropriate_templates
"app/views/hello_world/index.html.erb",
build_hello_world_view_config(
component_name: "HelloWorld",
source_path: "app/javascript/src/HelloWorld/",
source_path: example_component_source_path("HelloWorld"),
landing_page: new_app_landing_page_available?,
redux: false,
rsc_demo: false
Expand Down
Loading
Loading