diff --git a/.github/workflows/rspec-package-specs.yml b/.github/workflows/rspec-package-specs.yml index 80181f5c3..f91009741 100644 --- a/.github/workflows/rspec-package-specs.yml +++ b/.github/workflows/rspec-package-specs.yml @@ -47,6 +47,9 @@ jobs: git config user.email "you@example.com" git config user.name "Your Name" git commit -am "stop generators from complaining about uncommitted code" + - name: Set packer version environment variable + run: | + echo "CI_PACKER_VERSION=${{ matrix.versions == 'oldest' && 'old' || 'new' }}" >> $GITHUB_ENV - name: Run rspec tests run: bundle exec rspec spec/react_on_rails - name: Store test results diff --git a/Gemfile.lock b/Gemfile.lock index 054ab5a71..e5a50bccf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -385,6 +385,11 @@ GEM nokogiri (~> 1.6) rubyzip (>= 1.3.0) selenium-webdriver (~> 4.0, < 4.11) + webpacker (6.0.0.rc.6) + activesupport (>= 5.2) + rack-proxy (>= 0.6.1) + railties (>= 5.2) + semantic_range (>= 2.3.0) webrick (1.8.1) websocket (1.2.10) websocket-driver (0.7.6) @@ -439,6 +444,7 @@ DEPENDENCIES turbolinks uglifier webdrivers (= 5.3.0) + webpacker (= 6.0.0.rc.6) BUNDLED WITH 2.5.9 diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 2a468c413..8dbdc723a 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -17,6 +17,7 @@ def self.configuration # generated_assets_dirs is deprecated generated_assets_dir: "", server_bundle_js_file: "", + rsc_bundle_js_file: "", prerender: false, auto_load_bundle: false, replay_console: true, @@ -55,7 +56,7 @@ class Configuration :server_render_method, :random_dom_id, :auto_load_bundle, :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, - :defer_generated_component_packs, + :defer_generated_component_packs, :rsc_bundle_js_file, :force_load # rubocop:disable Metrics/AbcSize @@ -71,7 +72,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender same_bundle_for_client_and_server: nil, i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, - components_subdirectory: nil, auto_load_bundle: nil, force_load: nil) + components_subdirectory: nil, auto_load_bundle: nil, force_load: nil, + rsc_bundle_js_file: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir @@ -97,6 +99,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender # Server rendering: self.server_bundle_js_file = server_bundle_js_file + self.rsc_bundle_js_file = rsc_bundle_js_file self.same_bundle_for_client_and_server = same_bundle_for_client_and_server self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size self.server_renderer_timeout = server_renderer_timeout # seconds @@ -241,7 +244,7 @@ def ensure_webpack_generated_files_exists files = ["manifest.json"] files << server_bundle_js_file if server_bundle_js_file.present? - + files << rsc_bundle_js_file if rsc_bundle_js_file.present? self.webpack_generated_files = files end diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 1e179ad45..7246327ff 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -124,29 +124,15 @@ def react_component(component_name, options = {}) # @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering # Any other options are passed to the content tag, including the id. def stream_react_component(component_name, options = {}) - unless ReactOnRails::Utils.react_on_rails_pro? - raise ReactOnRails::Error, - "You must use React on Rails Pro to use the stream_react_component method." - end - - if @rorp_rendering_fibers.nil? - raise ReactOnRails::Error, - "You must call stream_view_containing_react_components to render the view containing the react component" + run_stream_inside_fiber do + internal_stream_react_component(component_name, options) end + end - rendering_fiber = Fiber.new do - stream = internal_stream_react_component(component_name, options) - stream.each_chunk do |chunk| - Fiber.yield chunk - end + def rsc_react_component(component_name, options = {}) + run_stream_inside_fiber do + internal_rsc_react_component(component_name, options) end - - @rorp_rendering_fibers << rendering_fiber - - # return the first chunk of the fiber - # It contains the initial html of the component - # all updates will be appended to the stream sent to browser - rendering_fiber.resume end # react_component_hash is used to return multiple HTML strings for server rendering, such as for @@ -388,6 +374,32 @@ def load_pack_for_generated_component(react_component_name, render_options) private + def run_stream_inside_fiber + unless ReactOnRails::Utils.react_on_rails_pro? + raise ReactOnRails::Error, + "You must use React on Rails Pro to use the stream_react_component method." + end + + if @rorp_rendering_fibers.nil? + raise ReactOnRails::Error, + "You must call stream_view_containing_react_components to render the view containing the react component" + end + + rendering_fiber = Fiber.new do + stream = yield + stream.each_chunk do |chunk| + Fiber.yield chunk + end + end + + @rorp_rendering_fibers << rendering_fiber + + # return the first chunk of the fiber + # It contains the initial html of the component + # all updates will be appended to the stream sent to browser + rendering_fiber.resume + end + def internal_stream_react_component(component_name, options = {}) options = options.merge(stream?: true) result = internal_react_component(component_name, options) @@ -398,6 +410,15 @@ def internal_stream_react_component(component_name, options = {}) ) end + def internal_rsc_react_component(react_component_name, options = {}) + options = options.merge(rsc?: true) + render_options = create_render_options(react_component_name, options) + json_stream = server_rendered_react_component(render_options) + json_stream.transform do |chunk| + chunk[:html].html_safe + end + end + def generated_components_pack_path(component_name) "#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js" end @@ -590,7 +611,7 @@ def should_raise_streaming_prerender_error?(chunk_json_result, render_options) end # Returns object with values that are NOT html_safe! - def server_rendered_react_component(render_options) + def server_rendered_react_component(render_options) # rubocop:disable Metrics/CyclomaticComplexity return { "html" => "", "consoleReplayScript" => "" } unless render_options.prerender react_component_name = render_options.react_component_name @@ -636,6 +657,9 @@ def server_rendered_react_component(render_options) js_code: js_code) end + # TODO: handle errors for rsc streams + return result if render_options.rsc? + if render_options.stream? result.transform do |chunk_json_result| if should_raise_streaming_prerender_error?(chunk_json_result, render_options) diff --git a/lib/react_on_rails/packer_utils.rb b/lib/react_on_rails/packer_utils.rb index 20b0cfeec..97999fe5e 100644 --- a/lib/react_on_rails/packer_utils.rb +++ b/lib/react_on_rails/packer_utils.rb @@ -74,9 +74,10 @@ def self.bundle_js_uri_from_packer(bundle_name) # the webpack-dev-server is provided by the config value # "same_bundle_for_client_and_server" where a value of true # would mean that the bundle is created by the webpack-dev-server - is_server_bundle = bundle_name == ReactOnRails.configuration.server_bundle_js_file + is_bundle_running_on_server = (bundle_name == ReactOnRails.configuration.server_bundle_js_file) || + (bundle_name == ReactOnRails.configuration.rsc_bundle_js_file) - if packer.dev_server.running? && (!is_server_bundle || + if packer.dev_server.running? && (!is_bundle_running_on_server || ReactOnRails.configuration.same_bundle_for_client_and_server) "#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}#{hashed_bundle_name}" else diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index f93ba85c2..8054e65a6 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -115,6 +115,10 @@ def stream? options[:stream?] end + def rsc? + options[:rsc?] + end + private attr_reader :options diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index 169c81d7d..99e03a980 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -46,7 +46,7 @@ def reset_pool_if_server_bundle_was_modified # Note, js_code does not have to be based on React. # js_code MUST RETURN json stringify Object # Calling code will probably call 'html_safe' on return value before rendering to the view. - # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def exec_server_render_js(js_code, render_options, js_evaluator = nil) js_evaluator ||= self if render_options.trace @@ -56,7 +56,7 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) @file_index += 1 end begin - result = if render_options.stream? + result = if render_options.stream? || render_options.rsc? js_evaluator.eval_streaming_js(js_code, render_options) else js_evaluator.eval_js(js_code, render_options) @@ -76,13 +76,16 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) raise ReactOnRails::Error, msg, err.backtrace end - return parse_result_and_replay_console_messages(result, render_options) unless render_options.stream? + unless render_options.stream? || render_options.rsc? + return parse_result_and_replay_console_messages(result, + render_options) + end # Streamed component is returned as stream of strings. # We need to parse each chunk and replay the console messages. result.transform { |chunk| parse_result_and_replay_console_messages(chunk, render_options) } end - # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false) return unless ReactOnRails.configuration.trace || force @@ -227,6 +230,8 @@ def file_url_to_string(url) end def parse_result_and_replay_console_messages(result_string, render_options) + return { html: result_string } if render_options.rsc? + result = nil begin result = JSON.parse(result_string) diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index bd50dd8da..6835aff2a 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -66,7 +66,7 @@ def self.server_bundle_path_is_http? server_bundle_js_file_path =~ %r{https?://} end - def self.server_bundle_js_file_path + def self.bundle_js_file_path(bundle_name) # Either: # 1. Using same bundle for both server and client, so server bundle will be hashed in manifest # 2. Using a different bundle (different Webpack config), so file is not hashed, and @@ -76,28 +76,17 @@ def self.server_bundle_js_file_path # a. The webpack manifest plugin would have a race condition where the same manifest.json # is edited by both the webpack-dev-server # b. There is no good reason to hash the server bundle name. - return @server_bundle_path if @server_bundle_path && !Rails.env.development? - - bundle_name = ReactOnRails.configuration.server_bundle_js_file - @server_bundle_path = if ReactOnRails::PackerUtils.using_packer? - begin - bundle_js_file_path(bundle_name) - rescue Object.const_get( - ReactOnRails::PackerUtils.packer_type.capitalize - )::Manifest::MissingEntryError - File.expand_path( - File.join(ReactOnRails::PackerUtils.packer_public_output_path, - bundle_name) - ) - end - else - bundle_js_file_path(bundle_name) - end - end - - def self.bundle_js_file_path(bundle_name) if ReactOnRails::PackerUtils.using_packer? && bundle_name != "manifest.json" - ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name) + begin + ReactOnRails::PackerUtils.bundle_js_uri_from_packer(bundle_name) + rescue Object.const_get( + ReactOnRails::PackerUtils.packer_type.capitalize + )::Manifest::MissingEntryError + File.expand_path( + File.join(ReactOnRails::PackerUtils.packer_public_output_path, + bundle_name) + ) + end else # Default to the non-hashed name in the specified output directory, which, for legacy # React on Rails, this is the output directory picked up by the asset pipeline. @@ -106,6 +95,20 @@ def self.bundle_js_file_path(bundle_name) end end + def self.server_bundle_js_file_path + return @server_bundle_path if @server_bundle_path && !Rails.env.development? + + bundle_name = ReactOnRails.configuration.server_bundle_js_file + @server_bundle_path = bundle_js_file_path(bundle_name) + end + + def self.rsc_bundle_js_file_path + return @rsc_bundle_path if @rsc_bundle_path && !Rails.env.development? + + bundle_name = ReactOnRails.configuration.rsc_bundle_js_file + @rsc_bundle_path = bundle_js_file_path(bundle_name) + end + def self.running_on_windows? (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil end diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 0ee89ad87..17723ad0d 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import type { Readable } from 'stream'; +import type { Readable, PassThrough } from 'stream'; import * as ClientStartup from './clientStartup'; import handleError from './handleError'; @@ -256,6 +256,14 @@ ctx.ReactOnRails = { return streamServerRenderedReactComponent(options); }, + /** + * Used by rsc payload generation by Rails + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + serverRenderRSCReactComponent(options: RenderParams): PassThrough { + throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only.'); + }, + /** * Used by Rails to catch errors in rendering * @param options diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts new file mode 100644 index 000000000..fa0ae10ef --- /dev/null +++ b/node_package/src/ReactOnRailsRSC.ts @@ -0,0 +1,84 @@ +// @ts-expect-error will define this module types later +import { renderToReadableStream } from 'react-server-dom-webpack/server.edge'; +import { PassThrough } from 'stream'; +import fs from 'fs'; + +import { RenderParams } from './types'; +import ComponentRegistry from './ComponentRegistry'; +import createReactOutput from './createReactOutput'; +import { isPromise, isServerRenderHash } from './isServerRenderResult'; +import ReactOnRails from './ReactOnRails'; + +const stringToStream = (str: string) => { + const stream = new PassThrough(); + stream.push(str); + stream.push(null); + return stream; +}; + +const getBundleConfig = () => { + const bundleConfig = JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8')); + // remove file:// from keys + const newBundleConfig: { [key: string]: unknown } = {}; + for (const [key, value] of Object.entries(bundleConfig)) { + newBundleConfig[key.replace('file://', '')] = value; + } + return newBundleConfig; +} + +ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => { + const { name, domNodeId, trace, props, railsContext, throwJsErrors } = options; + + let renderResult: null | PassThrough = null; + + try { + const componentObj = ComponentRegistry.get(name); + if (componentObj.isRenderer) { + throw new Error(`\ +Detected a renderer while server rendering component '${name}'. \ +See https://github.com/shakacode/react_on_rails#renderer-functions`); + } + + const reactRenderingResult = createReactOutput({ + componentObj, + domNodeId, + trace, + props, + railsContext, + }); + + if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) { + throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); + } + + renderResult = new PassThrough(); + let finalValue = ""; + const streamReader = renderToReadableStream(reactRenderingResult, getBundleConfig()).getReader(); + const decoder = new TextDecoder(); + const processStream = async () => { + const { done, value } = await streamReader.read(); + if (done) { + renderResult?.push(null); + // @ts-expect-error value is not typed + debugConsole.log('value', finalValue); + return; + } + + finalValue += decoder.decode(value); + renderResult?.push(value); + processStream(); + } + processStream(); + } catch (e: unknown) { + if (throwJsErrors) { + throw e; + } + + renderResult = stringToStream(`Error: ${e}`); + } + + return renderResult; +}; + +export * from './types'; +export default ReactOnRails; diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index a8f7ddffc..d48924bcd 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,5 +1,5 @@ import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; -import type { Readable } from 'stream'; +import type { Readable, PassThrough } from 'stream'; // Don't import redux just for the type definitions // See https://github.com/shakacode/react_on_rails/issues/1321 @@ -171,6 +171,7 @@ export interface ReactOnRails { getComponent(name: string): RegisteredComponent; serverRenderReactComponent(options: RenderParams): null | string | Promise; streamServerRenderedReactComponent(options: RenderParams): Readable; + serverRenderRSCReactComponent(options: RenderParams): PassThrough; handleError(options: ErrorOptions): string | undefined; buildConsoleReplay(): string; registeredComponents(): Map; diff --git a/package.json b/package.json index 004e6f181..62f94dd67 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,12 @@ "name": "react-on-rails", "version": "15.0.0-alpha.1", "description": "react-on-rails JavaScript for react_on_rails Ruby gem", - "main": "node_package/lib/ReactOnRails.js", + "exports": { + ".": { + "rsc-server": "./node_package/lib/ReactOnRailsRSC.js", + "default": "./node_package/lib/ReactOnRails.js" + } + }, "directories": { "doc": "docs" }, @@ -41,6 +46,7 @@ "prop-types": "^15.8.1", "react": "18.3.0-canary-670811593-20240322", "react-dom": "18.3.0-canary-670811593-20240322", + "react-server-dom-webpack": "18.3.0-canary-670811593-20240322", "react-transform-hmr": "^1.0.4", "redux": "^4.2.1", "ts-jest": "^29.1.0", diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock index 1a210af08..15d7af209 100644 --- a/spec/dummy/Gemfile.lock +++ b/spec/dummy/Gemfile.lock @@ -378,6 +378,11 @@ GEM nokogiri (~> 1.6) rubyzip (>= 1.3.0) selenium-webdriver (~> 4.0, < 4.11) + webpacker (6.0.0.rc.6) + activesupport (>= 5.2) + rack-proxy (>= 0.6.1) + railties (>= 5.2) + semantic_range (>= 2.3.0) webrick (1.8.1) websocket (1.2.9) websocket-driver (0.7.6) @@ -431,6 +436,7 @@ DEPENDENCIES turbolinks uglifier webdrivers (= 5.3.0) + webpacker (= 6.0.0.rc.6) BUNDLED WITH 2.5.9 diff --git a/spec/react_on_rails/configuration_spec.rb b/spec/react_on_rails/configuration_spec.rb index 84d978422..b76454edb 100644 --- a/spec/react_on_rails/configuration_spec.rb +++ b/spec/react_on_rails/configuration_spec.rb @@ -13,6 +13,8 @@ module ReactOnRails before do allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(using_packer) + allow(ReactOnRails::Utils).to receive(:gem_available?).and_call_original + allow(ReactOnRails::Utils).to receive(:gem_available?).with("webpacker").and_return(false) ReactOnRails.instance_variable_set(:@configuration, nil) end diff --git a/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb b/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb index 881222dbb..85ed00eb0 100644 --- a/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb +++ b/spec/react_on_rails/test_helper/webpack_assets_status_checker_spec.rb @@ -67,10 +67,10 @@ packer_public_output_path: generated_assets_full_path ) allow(ReactOnRails.configuration).to receive(:server_bundle_js_file).and_return("server-bundle.js") - allow(ReactOnRails::Utils).to receive(:bundle_js_file_path) + allow(ReactOnRails::PackerUtils).to receive(:bundle_js_uri_from_packer) .with("manifest.json") .and_return(File.join(generated_assets_full_path, "manifest.json")) - allow(ReactOnRails::Utils).to receive(:bundle_js_file_path) + allow(ReactOnRails::PackerUtils).to receive(:bundle_js_uri_from_packer) .with("server-bundle.js") .and_raise(Packer::Manifest::MissingEntryError) touch_files_in_dir(generated_assets_full_path) diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index a9ecbfa4d..d87873ef0 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -6,14 +6,124 @@ # rubocop:disable Metrics/ModuleLength, Metrics/BlockLength module ReactOnRails RSpec.describe Utils do + # Github Actions already run rspec tests two times, once with shakapacker and once with webpacker. + # If rspec tests are run locally, we want to test both packers. + # If rspec tests are run in CI, we want to test the packer specified in the CI_PACKER_VERSION environment variable. + # Check script/convert and .github/workflows/rspec-package-specs.yml for more details. + packers_to_test = if ENV["CI_PACKER_VERSION"] == "old" + ["webpacker"] + elsif ENV["CI_PACKER_VERSION"] == "new" + ["shakapacker"] + else + %w[shakapacker webpacker] + end + + shared_context "with packer enabled" do + before do + allow(ReactOnRails).to receive_message_chain(:configuration, :generated_assets_dir) + .and_return("") + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("dev_server.running?") + .and_return(false) + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("config.public_output_path") + .and_return(packer_public_output_path) + end + + it "uses packer" do + expect(ReactOnRails::PackerUtils.using_packer?).to be(true) + end + end + + shared_context "with shakapacker enabled" do + include_context "with packer enabled" + + # We don't need to mock anything here because the shakapacker gem is already installed and will be used by default + it "uses shakapacker" do + expect(ReactOnRails::PackerUtils.using_webpacker_const?).to be(false) + expect(ReactOnRails::PackerUtils.using_shakapacker_const?).to be(true) + expect(ReactOnRails::PackerUtils.packer_type).to eq("shakapacker") + expect(ReactOnRails::PackerUtils.packer).to eq(::Shakapacker) + end + end + + shared_context "with webpacker enabled" do + include_context "with packer enabled" + + it "uses webpacker" do + expect(ReactOnRails::PackerUtils.using_shakapacker_const?).to be(false) + expect(ReactOnRails::PackerUtils.using_webpacker_const?).to be(true) + expect(ReactOnRails::PackerUtils.packer_type).to eq("webpacker") + expect(ReactOnRails::PackerUtils.packer).to be_a(::Webpacker) + end + end + + shared_context "without packer enabled" do + before do + allow(ReactOnRails).to receive_message_chain(:configuration, :generated_assets_dir) + .and_return("public/webpack/dev") + allow(described_class).to receive(:gem_available?).with("shakapacker").and_return(false) + allow(described_class).to receive(:gem_available?).with("webpacker").and_return(false) + end + + it "does not use packer" do + expect(ReactOnRails::PackerUtils.using_packer?).to be(false) + expect(ReactOnRails::PackerUtils.packer_type).to be_nil + expect(ReactOnRails::PackerUtils.packer).to be_nil + end + end + + def mock_bundle_in_manifest(bundle_name, hashed_bundle) + mock_manifest = instance_double(Object.const_get(ReactOnRails::PackerUtils.packer_type.capitalize)::Manifest) + allow(mock_manifest).to receive(:lookup!) + .with(bundle_name) + .and_return(hashed_bundle) + + allow(ReactOnRails::PackerUtils.packer).to receive(:manifest).and_return(mock_manifest) + end + + def mock_missing_manifest_entry(bundle_name) + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("manifest.lookup!") + .with(bundle_name) + .and_raise(Object.const_get( + ReactOnRails::PackerUtils.packer_type.capitalize + )::Manifest::MissingEntryError) + end + + def random_bundle_name + "webpack-bundle-#{SecureRandom.hex(4)}.js" + end + + # If bundle names are not provided, random unique names will be used for each bundle. + # This ensures that if server_bundle and rsc_bundle are accidentally swapped in the code, + # the tests will fail since each bundle has a distinct random name that won't match if used incorrectly. + def mock_bundle_configs(server_bundle_name: random_bundle_name, rsc_bundle_name: random_bundle_name) + allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") + .and_return(server_bundle_name) + allow(ReactOnRails).to receive_message_chain("configuration.rsc_bundle_js_file") + .and_return(rsc_bundle_name) + end + + def mock_dev_server_running + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("dev_server.running?") + .and_return(true) + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("dev_server.protocol") + .and_return("http") + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("dev_server.host_with_port") + .and_return("localhost:3035") + end + context "when server_bundle_path cleared" do before do allow(Rails).to receive(:root).and_return(File.expand_path(".")) described_class.instance_variable_set(:@server_bundle_path, nil) + described_class.instance_variable_set(:@rsc_bundle_path, nil) + ReactOnRails::PackerUtils.instance_variables.each do |instance_variable| + ReactOnRails::PackerUtils.remove_instance_variable(instance_variable) + end end after do described_class.instance_variable_set(:@server_bundle_path, nil) + described_class.instance_variable_set(:@rsc_bundle_path, nil) end describe ".bundle_js_file_path" do @@ -21,51 +131,36 @@ module ReactOnRails described_class.bundle_js_file_path("webpack-bundle.js") end - context "with Shakapacker enabled", :shakapacker do - let(:packer_public_output_path) do - File.expand_path(File.join(Rails.root, "public/webpack/dev")) - end + packers_to_test.each do |packer_type| + context "with #{packer_type} enabled", packer_type.to_sym do + include_context "with #{packer_type} enabled" - before do - allow(ReactOnRails).to receive_message_chain(:configuration, :generated_assets_dir) - .and_return("") - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.dev_server.running?") - .and_return(false) - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.config.public_output_path") - .and_return(packer_public_output_path) - allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) - end - - context "when file in manifest", :shakapacker do - before do - # Note Shakapacker manifest lookup is inside of the public_output_path - # [2] (pry) ReactOnRails::PackerUtils: 0> Shakapacker.manifest.lookup("app-bundle.js") - # "/webpack/development/app-bundle-c1d2b6ab73dffa7d9c0e.js" - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.manifest.lookup!") - .with("webpack-bundle.js") - .and_return("/webpack/dev/webpack-bundle-0123456789abcdef.js") - allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") - .and_return("server-bundle.js") + let(:packer_public_output_path) do + File.expand_path(File.join(Rails.root, "public/webpack/dev")) end - it { is_expected.to eq("#{packer_public_output_path}/webpack-bundle-0123456789abcdef.js") } - end + context "when file in manifest", :shakapacker do + before do + mock_bundle_in_manifest("webpack-bundle.js", "/webpack/dev/webpack-bundle-0123456789abcdef.js") + + mock_bundle_configs(server_bundle_name: "server-bundle.js") + end - context "with manifest.json" do - subject do - described_class.bundle_js_file_path("manifest.json") + it { is_expected.to eq("#{packer_public_output_path}/webpack-bundle-0123456789abcdef.js") } end - it { is_expected.to eq("#{packer_public_output_path}/manifest.json") } + context "with manifest.json" do + subject do + described_class.bundle_js_file_path("manifest.json") + end + + it { is_expected.to eq("#{packer_public_output_path}/manifest.json") } + end end end context "without a packer enabled" do - before do - allow(ReactOnRails).to receive_message_chain(:configuration, :generated_assets_dir) - .and_return("public/webpack/dev") - allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(false) - end + include_context "without packer enabled" it { is_expected.to eq(File.expand_path(File.join(Rails.root, "public/webpack/dev/webpack-bundle.js"))) } end @@ -100,88 +195,130 @@ module ReactOnRails end end - describe ".server_bundle_js_file_path with Shakapacker enabled" do - before do - allow(Rails).to receive(:root).and_return(Pathname.new(".")) - allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.config.public_output_path") - .and_return(Pathname.new("public/webpack/development")) - end + packers_to_test.each do |packer_type| + describe ".server_bundle_js_file_path with #{packer_type} enabled" do + let(:packer_public_output_path) { Pathname.new("public/webpack/development") } - context "with server file not in manifest", :shakapacker do - it "returns the unhashed server path" do - server_bundle_name = "server-bundle.js" - allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") - .and_return(server_bundle_name) - allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("manifest.lookup!") - .with(server_bundle_name) - .and_raise(Object.const_get( - ReactOnRails::PackerUtils.packer_type.capitalize - )::Manifest::MissingEntryError) + include_context "with #{packer_type} enabled" - path = described_class.server_bundle_js_file_path + context "with server file not in manifest", packer_type.to_sym do + it "returns the unhashed server path" do + server_bundle_name = "server-bundle.js" + mock_bundle_configs(server_bundle_name: server_bundle_name) + mock_missing_manifest_entry(server_bundle_name) - expect(path).to end_with("public/webpack/development/#{server_bundle_name}") - end - end + path = described_class.server_bundle_js_file_path - context "with server file in the manifest, used for client", :shakapacker do - it "returns the correct path hashed server path" do - Packer = ReactOnRails::PackerUtils.packer # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration - allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") - .and_return("webpack-bundle.js") - allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") - .and_return(true) - allow(Packer).to receive_message_chain("manifest.lookup!") - .with("webpack-bundle.js") - .and_return("webpack/development/webpack-bundle-123456.js") - allow(Packer).to receive_message_chain("dev_server.running?") - .and_return(false) - - path = described_class.server_bundle_js_file_path - expect(path).to end_with("public/webpack/development/webpack-bundle-123456.js") - expect(path).to start_with("/") + expect(path).to end_with("public/webpack/development/#{server_bundle_name}") + end end - context "with webpack-dev-server running, and same file used for server and client" do + context "with server file in the manifest, used for client", packer_type.to_sym do it "returns the correct path hashed server path" do - allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") - .and_return("webpack-bundle.js") + Packer = ReactOnRails::PackerUtils.packer # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration + mock_bundle_configs(server_bundle_name: "webpack-bundle.js") allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") .and_return(true) - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.dev_server.running?") - .and_return(true) - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.dev_server.protocol") - .and_return("http") - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.dev_server.host_with_port") - .and_return("localhost:3035") - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.manifest.lookup!") - .with("webpack-bundle.js") - .and_return("/webpack/development/webpack-bundle-123456.js") + mock_bundle_in_manifest("webpack-bundle.js", "webpack/development/webpack-bundle-123456.js") + allow(Packer).to receive_message_chain("dev_server.running?") + .and_return(false) + + path = described_class.server_bundle_js_file_path + expect(path).to end_with("public/webpack/development/webpack-bundle-123456.js") + expect(path).to start_with("/") + end + + context "with webpack-dev-server running, and same file used for server and client" do + it "returns the correct path hashed server path" do + mock_bundle_configs(server_bundle_name: "webpack-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") + .and_return(true) + mock_dev_server_running + mock_bundle_in_manifest("webpack-bundle.js", "/webpack/development/webpack-bundle-123456.js") + + path = described_class.server_bundle_js_file_path + + expect(path).to eq("http://localhost:3035/webpack/development/webpack-bundle-123456.js") + end + end + end + + context "with dev-server running, and server file in the manifest, and separate client/server files", + packer_type.to_sym do + it "returns the correct path hashed server path" do + mock_bundle_configs(server_bundle_name: "server-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") + .and_return(false) + mock_bundle_in_manifest("server-bundle.js", "webpack/development/server-bundle-123456.js") + mock_dev_server_running path = described_class.server_bundle_js_file_path - expect(path).to eq("http://localhost:3035/webpack/development/webpack-bundle-123456.js") + expect(path).to end_with("/public/webpack/development/server-bundle-123456.js") end end end - context "with dev-server running, and server file in the manifest, and separate client/server files", - :shakapacker do - it "returns the correct path hashed server path" do - allow(ReactOnRails).to receive_message_chain("configuration.server_bundle_js_file") - .and_return("server-bundle.js") - allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") - .and_return(false) - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.manifest.lookup!") - .with("server-bundle.js") - .and_return("webpack/development/server-bundle-123456.js") - allow(ReactOnRails::PackerUtils).to receive_message_chain("packer.dev_server.running?") - .and_return(true) - - path = described_class.server_bundle_js_file_path - - expect(path).to end_with("/public/webpack/development/server-bundle-123456.js") + describe ".rsc_bundle_js_file_path with #{packer_type} enabled" do + let(:packer_public_output_path) { Pathname.new("public/webpack/development") } + + include_context "with #{packer_type} enabled" + + context "with server file not in manifest", packer_type.to_sym do + it "returns the unhashed server path" do + server_bundle_name = "rsc-bundle.js" + mock_bundle_configs(rsc_bundle_name: server_bundle_name) + mock_missing_manifest_entry(server_bundle_name) + + path = described_class.rsc_bundle_js_file_path + + expect(path).to end_with("public/webpack/development/#{server_bundle_name}") + end + end + + context "with server file in the manifest, used for client", packer_type.to_sym do + it "returns the correct path hashed server path" do + Packer = ReactOnRails::PackerUtils.packer # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration + mock_bundle_configs(rsc_bundle_name: "webpack-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") + .and_return(true) + mock_bundle_in_manifest("webpack-bundle.js", "webpack/development/webpack-bundle-123456.js") + allow(Packer).to receive_message_chain("dev_server.running?") + .and_return(false) + + path = described_class.rsc_bundle_js_file_path + expect(path).to end_with("public/webpack/development/webpack-bundle-123456.js") + expect(path).to start_with("/") + end + + context "with webpack-dev-server running, and same file used for server and client" do + it "returns the correct path hashed server path" do + mock_bundle_configs(rsc_bundle_name: "webpack-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") + .and_return(true) + mock_dev_server_running + mock_bundle_in_manifest("webpack-bundle.js", "/webpack/development/webpack-bundle-123456.js") + + path = described_class.rsc_bundle_js_file_path + + expect(path).to eq("http://localhost:3035/webpack/development/webpack-bundle-123456.js") + end + end + end + + context "with dev-server running, and server file in the manifest, and separate client/server files", + packer_type.to_sym do + it "returns the correct path hashed server path" do + mock_bundle_configs(rsc_bundle_name: "rsc-bundle.js") + allow(ReactOnRails).to receive_message_chain("configuration.same_bundle_for_client_and_server") + .and_return(false) + mock_bundle_in_manifest("rsc-bundle.js", "webpack/development/server-bundle-123456.js") + mock_dev_server_running + + path = described_class.rsc_bundle_js_file_path + + expect(path).to end_with("/public/webpack/development/server-bundle-123456.js") + end end end end diff --git a/yarn.lock b/yarn.lock index fac5f857e..290d5219a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1972,6 +1972,13 @@ acorn-jsx@^5.3.1: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== +acorn-loose@^8.3.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/acorn-loose/-/acorn-loose-8.4.0.tgz#26d3e219756d1e180d006f5bcc8d261a28530f55" + integrity sha512-M0EUka6rb+QC4l9Z3T0nJEzNOO7JcoJlYMrBlyBCiFSXRyxjLKayd4TbQs2FDRWQU1h9FR7QVNHt+PEaoNL5rQ== + dependencies: + acorn "^8.11.0" + acorn-walk@^8.0.2: version "8.3.1" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.1.tgz#2f10f5b69329d90ae18c58bf1fa8fccd8b959a43" @@ -2002,6 +2009,11 @@ acorn@^8.1.0, acorn@^8.8.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +acorn@^8.11.0: + version "8.12.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -5337,6 +5349,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +neo-async@^2.6.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -5902,6 +5919,14 @@ react-proxy@^1.1.7: lodash "^4.6.1" react-deep-force-update "^1.0.0" +react-server-dom-webpack@18.3.0-canary-670811593-20240322: + version "18.3.0-canary-670811593-20240322" + resolved "https://registry.yarnpkg.com/react-server-dom-webpack/-/react-server-dom-webpack-18.3.0-canary-670811593-20240322.tgz#e9b99b1f0179357e5acbf2fbacaee88dd1e8bf3b" + integrity sha512-YaCk3AvvOXcOo0FL7SlAY2GVBeuZKFQ/5FfAtE48IjpI6MvXTwMBu3QVnT/Ukk9Y4M9GzpIbLtuc8hPjfFAOaw== + dependencies: + acorn-loose "^8.3.0" + neo-async "^2.6.1" + react-transform-hmr@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/react-transform-hmr/-/react-transform-hmr-1.0.4.tgz#e1a40bd0aaefc72e8dfd7a7cda09af85066397bb"