diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3ae571c3..0264cf4ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/react_on_rails/lib/generators/react_on_rails/base_generator.rb b/react_on_rails/lib/generators/react_on_rails/base_generator.rb index 417cbc60a2..e7efcd52f1 100644 --- a/react_on_rails/lib/generators/react_on_rails/base_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/base_generator.rb @@ -188,11 +188,46 @@ def add_hello_world_route route "get 'hello_world', to: 'hello_world#index'" end + def copy_packer_config + # Rails generator actions run in method definition order. + # 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) || + instance_variable_defined?(:@shakapacker_source_entry_path) + raise Thor::Error, "copy_packer_config must run before path-dependent generator actions" + end + + base_path = "base/base/" + config = "config/shakapacker.yml" + use_rspack = using_rspack? + + 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 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 @@ -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? - 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 @@ -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")) end def add_base_gems_to_gemfile @@ -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 diff --git a/react_on_rails/lib/generators/react_on_rails/demo_page_config.rb b/react_on_rails/lib/generators/react_on_rails/demo_page_config.rb index 991ec58768..bf6e5c8fa4 100644 --- a/react_on_rails/lib/generators/react_on_rails/demo_page_config.rb +++ b/react_on_rails/lib/generators/react_on_rails/demo_page_config.rb @@ -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 } @@ -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." }, { diff --git a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb index c7e93f8666..bbb63b1a80 100644 --- a/react_on_rails/lib/generators/react_on_rails/generator_helper.rb +++ b/react_on_rails/lib/generators/react_on_rails/generator_helper.rb @@ -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 @@ -75,6 +80,108 @@ def component_extension(options) options.typescript? ? "tsx" : "jsx" end + def shakapacker_source_path + # 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) + 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?)) + end + + def shakapacker_stylesheet_path(filename) + # "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") + # 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? + + 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 + + def example_component_source_directory(component_name) + File.join(shakapacker_source_path, "src", component_name) + end + + def example_component_source_path(component_name) + # 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") + 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 + default + end + + 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| + 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) + candidate = path.to_s.strip + return default if candidate.empty? + + pathname = Pathname.new(candidate).cleanpath + # Shakapacker uses "/" to mean entrypoints live directly under source_path. + return "" if allow_root && pathname.to_s == "/" + + 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) + + relative_path + rescue ArgumentError # Pathname.new raises on null bytes in path strings. + default + end + + def absolute_path_relative_to_destination(pathname) + 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. + end + + def unsafe_generator_destination_path?(path) + 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 diff --git a/react_on_rails/lib/generators/react_on_rails/install_generator.rb b/react_on_rails/lib/generators/react_on_rails/install_generator.rb index 70cb20e7bf..6e2dcc5f46 100644 --- a/react_on_rails/lib/generators/react_on_rails/install_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/install_generator.rb @@ -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", [], @@ -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 + # 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 @@ -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") css_module_types_content = <<~TS.strip // TypeScript definitions for CSS modules declare module "*.module.css" { @@ -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 @@ -1222,7 +1221,7 @@ def create_typescript_config "jsx" => "react-jsx" }, "include" => [ - "app/javascript/**/*" + File.join(shakapacker_source_path, "**/*") ] } diff --git a/react_on_rails/lib/generators/react_on_rails/react_no_redux_generator.rb b/react_on_rails/lib/generators/react_on_rails/react_no_redux_generator.rb index e0d05a0c66..c2c48c9b1e 100644 --- a/react_on_rails/lib/generators/react_on_rails/react_no_redux_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/react_no_redux_generator.rb @@ -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}" + 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}", + server_component) end def create_appropriate_templates @@ -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 diff --git a/react_on_rails/lib/generators/react_on_rails/react_with_redux_generator.rb b/react_on_rails/lib/generators/react_on_rails/react_with_redux_generator.rb index ad9e9fec47..5af9c58390 100644 --- a/react_on_rails/lib/generators/react_on_rails/react_with_redux_generator.rb +++ b/react_on_rails/lib/generators/react_on_rails/react_with_redux_generator.rb @@ -41,42 +41,42 @@ class ReactWithReduxGenerator < Rails::Generators::Base hide: true def create_redux_directories + component_dir = example_component_source_directory("HelloWorldApp") + # Create auto-bundling directory structure for Redux - empty_directory("app/javascript/src/HelloWorldApp/ror_components") + empty_directory("#{component_dir}/ror_components") # Create Redux support directories within the component directory dirs = %w[actions constants containers reducers store components] - dirs.each { |name| empty_directory("app/javascript/src/HelloWorldApp/#{name}") } + dirs.each { |name| empty_directory("#{component_dir}/#{name}") } end def copy_base_files base_js_path = "redux/base" ext = component_extension(options) + component_dir = example_component_source_directory("HelloWorldApp") # Copy Redux-connected component to auto-bundling structure copy_file("#{base_js_path}/app/javascript/bundles/HelloWorld/startup/HelloWorldApp.client.#{ext}", - "app/javascript/src/HelloWorldApp/ror_components/HelloWorldApp.client.#{ext}") + "#{component_dir}/ror_components/HelloWorldApp.client.#{ext}") copy_file("#{base_js_path}/app/javascript/bundles/HelloWorld/startup/HelloWorldApp.server.#{ext}", - "app/javascript/src/HelloWorldApp/ror_components/HelloWorldApp.server.#{ext}") + "#{component_dir}/ror_components/HelloWorldApp.server.#{ext}") unless use_tailwind? copy_file("#{base_js_path}/app/javascript/bundles/HelloWorld/components/HelloWorld.module.css", - "app/javascript/src/HelloWorldApp/components/HelloWorld.module.css") + "#{component_dir}/components/HelloWorld.module.css") end - # Update import paths in client component - ror_client_file = "app/javascript/src/HelloWorldApp/ror_components/HelloWorldApp.client.#{ext}" - gsub_file(ror_client_file, "../store/helloWorldStore", "../store/helloWorldStore") - gsub_file(ror_client_file, "../containers/HelloWorldContainer", - "../containers/HelloWorldContainer") return unless use_tailwind? - stylesheet_import = "import '../../../stylesheets/application.css';\n" - ror_client_file_path = File.join(destination_root, ror_client_file) + ror_client_file = "#{component_dir}/ror_components/HelloWorldApp.client.#{ext}" if options[:pretend] say_status :pretend, "Would add Tailwind stylesheet import to #{ror_client_file}", :yellow return end + + stylesheet_import = "import '#{relative_stylesheet_import_path(ror_client_file)}';\n" + ror_client_file_path = File.join(destination_root, ror_client_file) return if File.read(ror_client_file_path).include?(stylesheet_import) prepend_to_file(ror_client_file, stylesheet_import) @@ -86,6 +86,7 @@ def copy_base_redux_files base_hello_world_path = "redux/base/app/javascript/bundles/HelloWorld" tailwind_hello_world_path = "redux/tailwind/app/javascript/bundles/HelloWorld" redux_extension = options.typescript? ? "ts" : "js" + component_dir = example_component_source_directory("HelloWorldApp") # Copy Redux infrastructure files with appropriate extension %W[actions/helloWorldActionCreators.#{redux_extension} @@ -94,13 +95,13 @@ def copy_base_redux_files reducers/helloWorldReducer.#{redux_extension} store/helloWorldStore.#{redux_extension}].each do |file| copy_file("#{base_hello_world_path}/#{file}", - "app/javascript/src/HelloWorldApp/#{file}") + "#{component_dir}/#{file}") end component_file = "components/HelloWorld.#{component_extension(options)}" component_source_path = use_tailwind? ? tailwind_hello_world_path : base_hello_world_path copy_file("#{component_source_path}/#{component_file}", - "app/javascript/src/HelloWorldApp/#{component_file}") + "#{component_dir}/#{component_file}") end def create_appropriate_templates @@ -111,7 +112,7 @@ def create_appropriate_templates "app/views/hello_world/index.html.erb", build_hello_world_view_config( component_name: "HelloWorldApp", - source_path: "app/javascript/src/HelloWorldApp/", + source_path: example_component_source_path("HelloWorldApp"), landing_page: new_app_landing_page_available?, redux: true, rsc_demo: options[:rsc] diff --git a/react_on_rails/lib/generators/react_on_rails/rsc_setup.rb b/react_on_rails/lib/generators/react_on_rails/rsc_setup.rb index d8d8825e53..0c441cea78 100644 --- a/react_on_rails/lib/generators/react_on_rails/rsc_setup.rb +++ b/react_on_rails/lib/generators/react_on_rails/rsc_setup.rb @@ -198,7 +198,7 @@ def add_rsc_to_procfile end def create_hello_server_component - hello_server_dir = "app/javascript/src/HelloServer" + hello_server_dir = example_component_source_directory("HelloServer") ror_components_dir = "#{hello_server_dir}/ror_components" components_dir = "#{hello_server_dir}/components" ext = component_extension(options) @@ -246,8 +246,7 @@ def add_tailwind_import_to_rsc_client_component(components_dir) end return false unless relative_entry_path - # Path is relative to app/javascript/src/HelloServer/components/. - stylesheet_import = "import '../../../stylesheets/application.css';" + stylesheet_import = "import '#{relative_stylesheet_import_path(relative_entry_path)}';" entry_path = File.join(destination_root, relative_entry_path) entry_content = File.read(entry_path) return false if entry_content.include?(stylesheet_import) @@ -296,7 +295,8 @@ def create_hello_server_view view_path, build_hello_server_view_config( landing_page: new_app_landing_page_available?, - redux_demo: options[:redux] + redux_demo: options[:redux], + source_path: example_component_source_path("HelloServer") )) say "✅ Created #{view_path}", :green diff --git a/react_on_rails/lib/generators/react_on_rails/templates/base/base/app/views/hello_world/index.html.erb.tt b/react_on_rails/lib/generators/react_on_rails/templates/base/base/app/views/hello_world/index.html.erb.tt index 40542a99db..54ade4e483 100644 --- a/react_on_rails/lib/generators/react_on_rails/templates/base/base/app/views/hello_world/index.html.erb.tt +++ b/react_on_rails/lib/generators/react_on_rails/templates/base/base/app/views/hello_world/index.html.erb.tt @@ -133,6 +133,13 @@ word-break: break-word; } + .path-hint { + display: inline-block; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + } + @media (max-width: 900px) { .hero, .card, @@ -181,7 +188,7 @@