diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b22a1cef6..cf9bd7e03 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }} + node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }} - name: Print system information run: | echo "Linux release: "; cat /etc/issue @@ -104,7 +104,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }} + node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }} - name: Print system information run: | echo "Linux release: "; cat /etc/issue diff --git a/.github/workflows/package-js-tests.yml b/.github/workflows/package-js-tests.yml index 81d4e4ce4..7ca6e499b 100644 --- a/.github/workflows/package-js-tests.yml +++ b/.github/workflows/package-js-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: ${{ matrix.versions == 'oldest' && '16' || '20' }} + node-version: ${{ matrix.versions == 'oldest' && '18' || '20' }} - name: Print system information run: | echo "Linux release: "; cat /etc/issue @@ -43,3 +43,6 @@ jobs: sudo yarn global add yalc - name: Run JS unit tests for Renderer package run: yarn test + # TODO: Remove this once we made these tests compatible with React 19 + - name: Run JS unit tests for Renderer package with React 18 (for tests not compatible with React 19) + run: yarn test:react-18 diff --git a/Gemfile.lock b/Gemfile.lock index d962a0c18..85315a312 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -385,11 +385,6 @@ 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) @@ -444,7 +439,6 @@ DEPENDENCIES turbolinks uglifier webdrivers (= 5.3.0) - webpacker (= 6.0.0.rc.6) BUNDLED WITH 2.5.9 diff --git a/jest.config.js b/jest.config.js index 09319b866..38eea025f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,15 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'jsdom', setupFiles: ['/node_package/tests/jest.setup.js'], + // TODO: Remove this once we made RSCClientRoot compatible with React 19 + moduleNameMapper: process.env.USE_REACT_18 + ? { + '^react$': '/node_modules/react-18', + '^react/(.*)$': '/node_modules/react-18/$1', + '^react-dom$': '/node_modules/react-dom-18', + '^react-dom/(.*)$': '/node_modules/react-dom-18/$1', + } + : { + 'react-server-dom-webpack/client': '/node_package/tests/emptyForTesting.js', + }, }; diff --git a/knip.ts b/knip.ts index a5ddede38..aae1c27da 100644 --- a/knip.ts +++ b/knip.ts @@ -9,6 +9,8 @@ const config: KnipConfig = { 'node_package/src/ReactOnRails.node.ts!', 'node_package/src/ReactOnRailsRSC.ts!', 'node_package/src/RSCWebpackLoader.js!', + 'node_package/src/registerServerComponent.ts!', + 'node_package/src/RSCClientRoot.ts!', ], project: ['node_package/src/**/*.[jt]s!', 'node_package/tests/**/*.[jt]s'], babel: { @@ -30,6 +32,10 @@ const config: KnipConfig = { 'eslint-plugin-jsx-a11y', 'eslint-plugin-react', 'react-server-dom-webpack', + 'cross-fetch', + 'jsdom', + 'react-18', + 'react-dom-18', ], }, 'spec/dummy': { diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 0e90ac800..4ef59b476 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -9,6 +9,7 @@ def self.configure end DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze + DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json" def self.configuration @configuration ||= Configuration.new( @@ -18,6 +19,7 @@ def self.configuration generated_assets_dir: "", server_bundle_js_file: "", rsc_bundle_js_file: "", + react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE, prerender: false, auto_load_bundle: false, replay_console: true, @@ -57,8 +59,8 @@ 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, :rsc_bundle_js_file, - :force_load + :defer_generated_component_packs, :force_load, :rsc_bundle_js_file, + :react_client_manifest_file # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -74,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, components_subdirectory: nil, auto_load_bundle: nil, force_load: nil, - rsc_bundle_js_file: nil) + rsc_bundle_js_file: nil, react_client_manifest_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 @@ -102,6 +104,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.react_client_manifest_file = react_client_manifest_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 @@ -247,6 +250,8 @@ 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? + files << react_client_manifest_file if react_client_manifest_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 7246327ff..9f26c78c4 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -124,6 +124,7 @@ 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 = {}) + options = options.merge(force_load: true) unless options.key?(:force_load) run_stream_inside_fiber do internal_stream_react_component(component_name, options) end @@ -193,17 +194,18 @@ def react_component_hash(component_name, options = {}) # props: Ruby Hash or JSON string which contains the properties to pass to the redux store. # Options # defer: false -- pass as true if you wish to render this below your component. - def redux_store(store_name, props: {}, defer: false) + # force_load: false -- pass as true if you wish to hydrate this store immediately instead of + # waiting for the page to load. + def redux_store(store_name, props: {}, defer: false, force_load: false) redux_store_data = { store_name: store_name, - props: props } + props: props, + force_load: force_load } if defer - @registered_stores_defer_render ||= [] - @registered_stores_defer_render << redux_store_data + registered_stores_defer_render << redux_store_data "YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \ "and not <%= redux store %>" else - @registered_stores ||= [] - @registered_stores << redux_store_data + registered_stores << redux_store_data result = render_redux_store_data(redux_store_data) prepend_render_rails_context(result) end @@ -215,9 +217,9 @@ def redux_store(store_name, props: {}, defer: false) # client side rendering of this hydration data, which is a hidden div with a matching class # that contains a data props. def redux_store_hydration_data - return if @registered_stores_defer_render.blank? + return if registered_stores_defer_render.blank? - @registered_stores_defer_render.reduce(+"") do |accum, redux_store_data| + registered_stores_defer_render.reduce(+"") do |accum, redux_store_data| accum << render_redux_store_data(redux_store_data) end.html_safe end @@ -400,6 +402,25 @@ def run_stream_inside_fiber rendering_fiber.resume end + def registered_stores + @registered_stores ||= [] + end + + def registered_stores_defer_render + @registered_stores_defer_render ||= [] + end + + def registered_stores_including_deferred + registered_stores + registered_stores_defer_render + end + + def create_render_options(react_component_name, options) + # If no store dependencies are passed, default to all registered stores up till now + options[:store_dependencies] ||= registered_stores_including_deferred.map { |store| store[:store_name] } + ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, + options: options) + end + def internal_stream_react_component(component_name, options = {}) options = options.merge(stream?: true) result = internal_react_component(component_name, options) @@ -415,7 +436,7 @@ def internal_rsc_react_component(react_component_name, options = {}) 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 + "#{chunk.to_json}\n".html_safe end end @@ -510,7 +531,8 @@ def build_react_component_result_for_server_rendered_hash( ) end - def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script) + def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, + console_script) # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. html_content = <<~HTML #{rendered_output} @@ -546,8 +568,7 @@ def internal_react_component(react_component_name, options = {}) # (re-hydrate the data). This enables react rendered on the client to see that the # server has already rendered the HTML. - render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, - options: options) + render_options = create_render_options(react_component_name, options) # Setup the page_loaded_js, which is the same regardless of prerendering or not! # The reason is that React is smart about not doing extra work if the server rendering did its job. @@ -555,9 +576,12 @@ def internal_react_component(react_component_name, options = {}) json_safe_and_pretty(render_options.client_props).html_safe, type: "application/json", class: "js-react-on-rails-component", + id: "js-react-on-rails-component-#{render_options.dom_id}", "data-component-name" => render_options.react_component_name, "data-trace" => (render_options.trace ? true : nil), - "data-dom-id" => render_options.dom_id) + "data-dom-id" => render_options.dom_id, + "data-store-dependencies" => render_options.store_dependencies.to_json, + "data-force-load" => (render_options.force_load ? true : nil)) if render_options.force_load component_specification_tag.concat( @@ -579,12 +603,22 @@ def internal_react_component(react_component_name, options = {}) end def render_redux_store_data(redux_store_data) - result = content_tag(:script, - json_safe_and_pretty(redux_store_data[:props]).html_safe, - type: "application/json", - "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe) + store_hydration_data = content_tag(:script, + json_safe_and_pretty(redux_store_data[:props]).html_safe, + type: "application/json", + "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe, + "data-force-load" => (redux_store_data[:force_load] ? true : nil)) + + if redux_store_data[:force_load] + store_hydration_data.concat( + content_tag(:script, <<~JS.strip_heredoc.html_safe + ReactOnRails.reactOnRailsStoreLoaded('#{redux_store_data[:store_name]}'); + JS + ) + ) + end - prepend_render_rails_context(result) + prepend_render_rails_context(store_hydration_data) end def props_string(props) @@ -641,7 +675,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code( props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'), rails_context: rails_context(server_side: true).to_json, - redux_stores: initialize_redux_stores, + redux_stores: initialize_redux_stores(render_options), react_component_name: react_component_name, render_options: render_options ) @@ -657,10 +691,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy js_code: js_code) end - # TODO: handle errors for rsc streams - return result if render_options.rsc? - - if render_options.stream? + if render_options.stream? || render_options.rsc? result.transform do |chunk_json_result| if should_raise_streaming_prerender_error?(chunk_json_result, render_options) raise_prerender_error(chunk_json_result, react_component_name, props, js_code) @@ -675,17 +706,20 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy result end - def initialize_redux_stores + def initialize_redux_stores(render_options) result = +<<-JS ReactOnRails.clearHydratedStores(); JS - return result unless @registered_stores.present? || @registered_stores_defer_render.present? + store_dependencies = render_options.store_dependencies + return result unless store_dependencies.present? declarations = +"var reduxProps, store, storeGenerator;\n" - all_stores = (@registered_stores || []) + (@registered_stores_defer_render || []) + store_objects = registered_stores_including_deferred.select do |store| + store_dependencies.include?(store[:store_name]) + end - result << all_stores.each_with_object(declarations) do |redux_store_data, memo| + result << store_objects.each_with_object(declarations) do |redux_store_data, memo| store_name = redux_store_data[:store_name] props = props_string(redux_store_data[:props]) memo << <<-JS.strip_heredoc diff --git a/lib/react_on_rails/packer_utils.rb b/lib/react_on_rails/packer_utils.rb index 97999fe5e..df71afd4b 100644 --- a/lib/react_on_rails/packer_utils.rb +++ b/lib/react_on_rails/packer_utils.rb @@ -45,6 +45,10 @@ def self.dev_server_running? packer.dev_server.running? end + def self.dev_server_url + "#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}" + end + def self.shakapacker_version return @shakapacker_version if defined?(@shakapacker_version) return nil unless ReactOnRails::Utils.gem_available?("shakapacker") @@ -79,12 +83,27 @@ def self.bundle_js_uri_from_packer(bundle_name) 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}" + "#{dev_server_url}#{hashed_bundle_name}" else File.expand_path(File.join("public", hashed_bundle_name)).to_s end end + def self.public_output_uri_path + "#{packer.config.public_output_path.relative_path_from(packer.config.public_path)}/" + end + + # The function doesn't ensure that the asset exists. + # - It just returns url to the asset if dev server is running + # - Otherwise it returns file path to the asset + def self.asset_uri_from_packer(asset_name) + if dev_server_running? + "#{dev_server_url}/#{public_output_uri_path}#{asset_name}" + else + File.join(packer_public_output_path, asset_name).to_s + end + end + def self.precompile? return ::Webpacker.config.webpacker_precompile? if using_webpacker_const? return ::Shakapacker.config.shakapacker_precompile? if using_shakapacker_const? diff --git a/lib/react_on_rails/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index 2db3d6b0c..30be75993 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -44,11 +44,71 @@ def create_pack(file_path) puts(Rainbow("Generated Packs: #{output_path}").yellow) end + def first_js_statement_in_code(content) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + return "" if content.nil? || content.empty? + + start_index = 0 + content_length = content.length + + while start_index < content_length + # Skip whitespace + start_index += 1 while start_index < content_length && content[start_index].match?(/\s/) + + break if start_index >= content_length + + current_chars = content[start_index, 2] + + case current_chars + when "//" + # Single-line comment + newline_index = content.index("\n", start_index) + return "" if newline_index.nil? + + start_index = newline_index + 1 + when "/*" + # Multi-line comment + comment_end = content.index("*/", start_index) + return "" if comment_end.nil? + + start_index = comment_end + 2 + else + # Found actual content + next_line_index = content.index("\n", start_index) + return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip + end + end + + "" + end + + def client_entrypoint?(file_path) + content = File.read(file_path) + # has "use client" directive. It can be "use client" or 'use client' + first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/) + end + def pack_file_contents(file_path) registered_component_name = component_name(file_path) - <<~FILE_CONTENT + load_server_components = ReactOnRails::Utils.react_on_rails_pro? && + ReactOnRailsPro.configuration.enable_rsc_support + + if load_server_components && !client_entrypoint?(file_path) + rsc_rendering_url_path = ReactOnRailsPro.configuration.rsc_rendering_url_path + + return <<~FILE_CONTENT.strip + import registerServerComponent from 'react-on-rails/registerServerComponent'; + + registerServerComponent({ + rscRenderingUrlPath: "#{rsc_rendering_url_path}", + }, "#{registered_component_name}") + FILE_CONTENT + end + + relative_component_path = relative_component_path_from_generated_pack(file_path) + + <<~FILE_CONTENT.strip import ReactOnRails from 'react-on-rails'; - import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}'; + import #{registered_component_name} from '#{relative_component_path}'; ReactOnRails.register({#{registered_component_name}}); FILE_CONTENT diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 8054e65a6..1725df070 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -119,6 +119,10 @@ def rsc? options[:rsc?] end + def store_dependencies + options[:store_dependencies] + 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 99e03a980..792fc85fd 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 @@ -19,6 +19,10 @@ def reset_pool def reset_pool_if_server_bundle_was_modified return unless ReactOnRails.configuration.development_mode + # RSC (React Server Components) bundle changes are not monitored here since: + # 1. RSC is only supported in the Pro version of React on Rails + # 2. This RubyEmbeddedJavaScript pool is used exclusively in the non-Pro version + # 3. This pool uses ExecJS for JavaScript evaluation which does not support RSC if ReactOnRails::Utils.server_bundle_path_is_http? return if @server_bundle_url == ReactOnRails::Utils.server_bundle_js_file_path @@ -230,8 +234,6 @@ 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/test_helper/webpack_assets_status_checker.rb b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb index e951df322..f3014af8d 100644 --- a/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +++ b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb @@ -50,8 +50,8 @@ def stale_generated_files(files) def all_compiled_assets @all_compiled_assets ||= begin webpack_generated_files = @webpack_generated_files.map do |bundle_name| - if bundle_name == ReactOnRails.configuration.server_bundle_js_file - ReactOnRails::Utils.server_bundle_js_file_path + if bundle_name == ReactOnRails.configuration.react_client_manifest_file + ReactOnRails::Utils.react_client_manifest_file_path else ReactOnRails::Utils.bundle_js_file_path(bundle_name) end diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index 6835aff2a..c1a794a8f 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -109,6 +109,17 @@ def self.rsc_bundle_js_file_path @rsc_bundle_path = bundle_js_file_path(bundle_name) end + def self.react_client_manifest_file_path + return @react_client_manifest_path if @react_client_manifest_path && !Rails.env.development? + + file_name = ReactOnRails.configuration.react_client_manifest_file + @react_client_manifest_path = if ReactOnRails::PackerUtils.using_packer? + ReactOnRails::PackerUtils.asset_uri_from_packer(file_name) + else + File.join(generated_assets_full_path, file_name) + end + end + def self.running_on_windows? (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil end diff --git a/node_package/src/CallbackRegistry.ts b/node_package/src/CallbackRegistry.ts new file mode 100644 index 000000000..ee6cedb44 --- /dev/null +++ b/node_package/src/CallbackRegistry.ts @@ -0,0 +1,50 @@ +import { ItemRegistrationCallback } from "./types"; + +export default class CallbackRegistry { + private registeredItems = new Map(); + private callbacks = new Map>>(); + + set(name: string, item: T): void { + this.registeredItems.set(name, item); + + const callbacks = this.callbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(item), 0); + }); + this.callbacks.delete(name); + } + + get(name: string): T | undefined { + return this.registeredItems.get(name); + } + + has(name: string): boolean { + return this.registeredItems.has(name); + } + + clear(): void { + this.registeredItems.clear(); + } + + getAll(): Map { + return this.registeredItems; + } + + onItemRegistered(name: string, callback: ItemRegistrationCallback): void { + const existingItem = this.registeredItems.get(name); + if (existingItem) { + setTimeout(() => callback(existingItem), 0); + return; + } + + const callbacks = this.callbacks.get(name) || []; + callbacks.push(callback); + this.callbacks.set(name, callbacks); + } + + getOrWaitForItem(name: string): Promise { + return new Promise((resolve) => { + this.onItemRegistered(name, resolve); + }); + } +} diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts new file mode 100644 index 000000000..23f775987 --- /dev/null +++ b/node_package/src/ClientSideRenderer.ts @@ -0,0 +1,293 @@ +import ReactDOM from 'react-dom'; +import type { ReactElement } from 'react'; +import type { + RailsContext, + RegisteredComponent, + RenderFunction, + Root, +} from './types'; + +import { reactOnRailsContext, type Context } from './context'; +import createReactOutput from './createReactOutput'; +import { isServerRenderHash } from './isServerRenderResult'; +import reactHydrateOrRender from './reactHydrateOrRender'; +import { supportsRootApi } from './reactApis'; + +const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; + +function delegateToRenderer( + componentObj: RegisteredComponent, + props: Record, + railsContext: RailsContext, + domNodeId: string, + trace: boolean, +): boolean { + const { name, component, isRenderer } = componentObj; + + if (isRenderer) { + if (trace) { + console.log(`\ +DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, + props, railsContext); + } + + (component as RenderFunction)(props, railsContext, domNodeId); + return true; + } + + return false; +} + +const getDomId = (domIdOrElement: string | Element): string => typeof domIdOrElement === 'string' ? domIdOrElement : domIdOrElement.getAttribute('data-dom-id') || ''; + +let currentContext: Context | null = null; +let currentRailsContext: RailsContext | null = null; + +// caches context and railsContext to avoid re-parsing rails-context each time a component is rendered +// Cached values will be reset when unmountAll() is called +function getContextAndRailsContext(): { context: Context | null; railsContext: RailsContext | null } { + // Return cached values if already set + if (currentContext && currentRailsContext) { + return { context: currentContext, railsContext: currentRailsContext }; + } + + currentContext = reactOnRailsContext(); + + const el = document.getElementById('js-react-on-rails-context'); + if (!el || !el.textContent) { + return { context: null, railsContext: null }; + } + + try { + currentRailsContext = JSON.parse(el.textContent); + } catch (e) { + console.error('Error parsing rails context:', e); + return { context: null, railsContext: null }; + } + + return { context: currentContext, railsContext: currentRailsContext }; +} + +class ComponentRenderer { + private domNodeId: string; + private state: 'unmounted' | 'rendering' | 'rendered'; + private root?: Root; + private renderPromise?: Promise; + + constructor(domIdOrElement: string | Element) { + const domId = getDomId(domIdOrElement); + this.domNodeId = domId; + this.state = 'rendering'; + const el = typeof domIdOrElement === 'string' ? document.querySelector(`[data-dom-id=${domId}]`) : domIdOrElement; + if (!el) return; + + const storeDependencies = el.getAttribute('data-store-dependencies'); + const storeDependenciesArray = storeDependencies ? JSON.parse(storeDependencies) as string[] : []; + + const { context, railsContext } = getContextAndRailsContext(); + if (!context || !railsContext) return; + + // Wait for all store dependencies to be loaded + this.renderPromise = Promise.all( + storeDependenciesArray.map(storeName => context.ReactOnRails.getOrWaitForStore(storeName)), + ).then(() => { + if (this.state === 'unmounted') return Promise.resolve(); + return this.render(el, context, railsContext); + }); + } + + /** + * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or + * delegates to a renderer registered by the user. + */ + private async render(el: Element, context: Context, railsContext: RailsContext): Promise { + // This must match lib/react_on_rails/helper.rb + const name = el.getAttribute('data-component-name') || ''; + const { domNodeId } = this; + const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; + const trace = el.getAttribute('data-trace') === 'true'; + + try { + const domNode = document.getElementById(domNodeId); + if (domNode) { + const componentObj = await context.ReactOnRails.getOrWaitForComponent(name); + if (this.state === 'unmounted') { + return; + } + + if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { + return; + } + + // Hydrate if available and was server rendered + // @ts-expect-error potentially present if React 18 or greater + const shouldHydrate = !!(ReactDOM.hydrate || ReactDOM.hydrateRoot) && !!domNode.innerHTML; + + const reactElementOrRouterResult = createReactOutput({ + componentObj, + props, + domNodeId, + trace, + railsContext, + shouldHydrate, + }); + + if (isServerRenderHash(reactElementOrRouterResult)) { + throw new Error(`\ + You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} + You should return a React.Component always for the client side entry point.`); + } else { + const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate); + this.state = 'rendered'; + if (supportsRootApi) { + this.root = rootOrElement as Root; + } + } + } + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error('Unknown error'); + console.error(error.message); + error.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.` + throw error; + } + } + + unmount(): void { + if (this.state === 'rendering') { + this.state = 'unmounted'; + return; + } + this.state = 'unmounted'; + + if (supportsRootApi) { + this.root?.unmount(); + this.root = undefined; + } else { + const domNode = document.getElementById(this.domNodeId); + if (!domNode) { + return; + } + + try { + ReactDOM.unmountComponentAtNode(domNode); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error('Unknown error'); + console.info(`Caught error calling unmountComponentAtNode: ${error.message} for domNode`, + domNode, error); + } + } + } + + waitUntilRendered(): Promise { + if (this.state === 'rendering') { + return this.renderPromise!; + } + return Promise.resolve(); + } +} + +class StoreRenderer { + private hydratePromise?: Promise; + private state: 'unmounted' | 'hydrating' | 'hydrated'; + + constructor(storeDataElement: Element) { + this.state = 'hydrating'; + const { context, railsContext } = getContextAndRailsContext(); + if (!context || !railsContext) { + return; + } + + const name = storeDataElement.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; + const props = (storeDataElement.textContent !== null) ? JSON.parse(storeDataElement.textContent) : {}; + this.hydratePromise = this.hydrate(context, railsContext, name, props); + } + + private async hydrate(context: Context, railsContext: RailsContext, name: string, props: Record) { + const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name); + if (this.state === 'unmounted') { + return; + } + + const store = storeGenerator(props, railsContext); + context.ReactOnRails.setStore(name, store); + this.state = 'hydrated'; + } + + waitUntilHydrated(): Promise { + if (this.state === 'hydrating') { + return this.hydratePromise!; + } + return Promise.resolve(); + } + + unmount(): void { + this.state = 'unmounted'; + } +} + +const renderedRoots = new Map(); + +export function renderOrHydrateComponent(domIdOrElement: string | Element): ComponentRenderer | undefined { + const domId = getDomId(domIdOrElement); + let root = renderedRoots.get(domId); + if (!root) { + root = new ComponentRenderer(domIdOrElement); + renderedRoots.set(domId, root); + } + return root; +} + + +export function renderOrHydrateForceLoadedComponents(): void { + const els = document.querySelectorAll(`.js-react-on-rails-component[data-force-load="true"]`); + els.forEach((el) => renderOrHydrateComponent(el)); +} + +export function renderOrHydrateAllComponents(): void { + const els = document.querySelectorAll(`.js-react-on-rails-component`); + els.forEach((el) => renderOrHydrateComponent(el)); +} + +function unmountAllComponents(): void { + renderedRoots.forEach((root) => root.unmount()); + renderedRoots.clear(); + currentContext = null; + currentRailsContext = null; +} + +const storeRenderers = new Map(); + +export async function hydrateStore(storeNameOrElement: string | Element) { + const storeName = typeof storeNameOrElement === 'string' ? storeNameOrElement : storeNameOrElement.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; + let storeRenderer = storeRenderers.get(storeName); + if (!storeRenderer) { + const storeDataElement = typeof storeNameOrElement === 'string' ? document.querySelector(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}="${storeNameOrElement}"]`) : storeNameOrElement; + if (!storeDataElement) { + return; + } + + storeRenderer = new StoreRenderer(storeDataElement); + storeRenderers.set(storeName, storeRenderer); + } + await storeRenderer.waitUntilHydrated(); +} + +export async function hydrateForceLoadedStores(): Promise { + const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}][data-force-load="true"]`); + await Promise.all(Array.from(els).map((el) => hydrateStore(el))); +} + +export async function hydrateAllStores(): Promise { + const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`); + await Promise.all(Array.from(els).map((el) => hydrateStore(el))); +} + +function unmountAllStores(): void { + storeRenderers.forEach((storeRenderer) => storeRenderer.unmount()); + storeRenderers.clear(); +} + +export function unmountAll(): void { + unmountAllComponents(); + unmountAllStores(); +} diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index a8f42dd27..567259513 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,15 +1,33 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index'; +import { + type RegisteredComponent, + type ReactComponentOrRenderFunction, + type RenderFunction, + type ItemRegistrationCallback, +} from './types'; import isRenderFunction from './isRenderFunction'; +import CallbackRegistry from './CallbackRegistry'; -const registeredComponents = new Map(); +const componentRegistry = new CallbackRegistry(); export default { + /** + * Register a callback to be called when a specific component is registered + * @param componentName Name of the component to watch for + * @param callback Function called with the component details when registered + */ + onComponentRegistered( + componentName: string, + callback: ItemRegistrationCallback, + ): void { + componentRegistry.onItemRegistered(componentName, callback); + }, + /** * @param components { component1: component1, component2: component2, etc. } */ register(components: { [id: string]: ReactComponentOrRenderFunction }): void { Object.keys(components).forEach(name => { - if (registeredComponents.has(name)) { + if (componentRegistry.has(name)) { console.warn('Called register for component that is already registered', name); } @@ -21,7 +39,7 @@ export default { const renderFunction = isRenderFunction(component); const isRenderer = renderFunction && (component as RenderFunction).length === 3; - registeredComponents.set(name, { + componentRegistry.set(name, { name, component, renderFunction, @@ -35,22 +53,24 @@ export default { * @returns { name, component, isRenderFunction, isRenderer } */ get(name: string): RegisteredComponent { - const registeredComponent = registeredComponents.get(name); - if (registeredComponent !== undefined) { - return registeredComponent; - } + const component = componentRegistry.get(name); + if (component !== undefined) return component; - const keys = Array.from(registeredComponents.keys()).join(', '); + const keys = Array.from(componentRegistry.getAll().keys()).join(', '); throw new Error(`Could not find component registered with name ${name}. \ Registered component names include [ ${keys} ]. Maybe you forgot to register the component?`); }, + getOrWaitForComponent(name: string): Promise { + return componentRegistry.getOrWaitForItem(name); + }, + /** * Get a Map containing all registered components. Useful for debugging. * @returns Map where key is the component name and values are the * { name, component, renderFunction, isRenderer} */ components(): Map { - return registeredComponents; + return componentRegistry.getAll(); }, }; diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts new file mode 100644 index 000000000..b827bc272 --- /dev/null +++ b/node_package/src/RSCClientRoot.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import RSDWClient from 'react-server-dom-webpack/client'; +import { fetch } from './utils'; +import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs'; + +if (!('use' in React && typeof React.use === 'function')) { + throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.'); +} + +const { use } = React; + +let renderCache: Record> = {}; +export const resetRenderCache = () => { + renderCache = {}; +} + +export type RSCClientRootProps = { + componentName: string; + rscRenderingUrlPath: string; +} + +const createFromFetch = async (fetchPromise: Promise) => { + const response = await fetchPromise; + const stream = response.body; + if (!stream) { + throw new Error('No stream found in response'); + } + const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream); + return RSDWClient.createFromReadableStream(transformedStream); +} + +const fetchRSC = ({ componentName, rscRenderingUrlPath }: RSCClientRootProps) => { + if (!renderCache[componentName]) { + const strippedUrlPath = rscRenderingUrlPath.replace(/^\/|\/$/g, ''); + renderCache[componentName] = createFromFetch(fetch(`/${strippedUrlPath}/${componentName}`)) as Promise; + } + return renderCache[componentName]; +} + +const RSCClientRoot = ({ + componentName, + rscRenderingUrlPath, +}: RSCClientRootProps) => use(fetchRSC({ componentName, rscRenderingUrlPath })); + +export default RSCClientRoot; diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 01f54f291..02bcec4d5 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import * as ClientStartup from './clientStartup'; +import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer'; import handleError from './handleError'; import ComponentRegistry from './ComponentRegistry'; import StoreRegistry from './StoreRegistry'; @@ -85,6 +86,24 @@ ctx.ReactOnRails = { return StoreRegistry.getStore(name, throwIfMissing); }, + /** + * Get a store by name, or wait for it to be registered. + * @param name + * @returns Promise + */ + getOrWaitForStore(name: string): Promise { + return StoreRegistry.getOrWaitForStore(name); + }, + + /** + * Get a store generator by name, or wait for it to be registered. + * @param name + * @returns Promise + */ + getOrWaitForStoreGenerator(name: string): Promise { + return StoreRegistry.getOrWaitForStoreGenerator(name); + }, + /** * Renders or hydrates the react element passed. In case react version is >=18 will use the new api. * @param domNode @@ -135,7 +154,11 @@ ctx.ReactOnRails = { }, reactOnRailsComponentLoaded(domId: string): void { - ClientStartup.reactOnRailsComponentLoaded(domId); + renderOrHydrateComponent(domId); + }, + + reactOnRailsStoreLoaded(storeName: string): void { + hydrateStore(storeName); }, /** @@ -238,6 +261,15 @@ ctx.ReactOnRails = { return ComponentRegistry.get(name); }, + /** + * Get the component that you registered, or wait for it to be registered + * @param name + * @returns {name, component, renderFunction, isRenderer} + */ + getOrWaitForComponent(name: string): Promise { + return ComponentRegistry.getOrWaitForComponent(name); + }, + /** * Used by server rendering by Rails * @param options diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index fa0ae10ef..5c4bd24e5 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -1,13 +1,21 @@ -// @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 { renderToPipeableStream } from 'react-server-dom-webpack/server.node'; +import { PassThrough, Readable } from 'stream'; +import type { ReactElement } from 'react'; -import { RenderParams } from './types'; -import ComponentRegistry from './ComponentRegistry'; -import createReactOutput from './createReactOutput'; -import { isPromise, isServerRenderHash } from './isServerRenderResult'; +import { RSCRenderParams, StreamRenderState } from './types'; import ReactOnRails from './ReactOnRails'; +import buildConsoleReplay from './buildConsoleReplay'; +import handleError from './handleError'; +import { + convertToError, + createResultObject, +} from './serverRenderUtils'; + +import { + streamServerRenderedComponent, + transformRenderStreamChunksToResultObject, +} from './streamServerRenderedReactComponent'; +import loadReactClientManifest from './loadReactClientManifest'; const stringToStream = (str: string) => { const stream = new PassThrough(); @@ -16,68 +24,49 @@ const stringToStream = (str: string) => { 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; +const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRenderParams): Readable => { + const { throwJsErrors, reactClientManifestFileName } = options; + const renderState: StreamRenderState = { + result: null, + hasErrors: false, + isShellReady: true + }; + const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState); 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; + const rscStream = renderToPipeableStream( + reactElement, + loadReactClientManifest(reactClientManifestFileName), + { + onError: (err) => { + const error = convertToError(err); + console.error("Error in RSC stream", error); + if (throwJsErrors) { + emitError(error); + } + renderState.hasErrors = true; + renderState.error = error; + } } - - finalValue += decoder.decode(value); - renderResult?.push(value); - processStream(); - } - processStream(); - } catch (e: unknown) { - if (throwJsErrors) { - throw e; - } - - renderResult = stringToStream(`Error: ${e}`); + ); + pipeToTransform(rscStream); + return readableStream; + } catch (e) { + const error = convertToError(e); + renderState.hasErrors = true; + renderState.error = error; + const htmlResult = handleError({ e: error, name: options.name, serverSide: true }); + const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), renderState)); + return stringToStream(jsonResult); } +}; - return renderResult; +ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { + try { + return streamServerRenderedComponent(options, streamRenderRSCComponent); + } finally { + console.history = []; + } }; export * from './types'; diff --git a/node_package/src/StoreRegistry.ts b/node_package/src/StoreRegistry.ts index 7be95e6f3..7f054ef26 100644 --- a/node_package/src/StoreRegistry.ts +++ b/node_package/src/StoreRegistry.ts @@ -1,7 +1,8 @@ -import type { Store, StoreGenerator } from './types'; +import CallbackRegistry from './CallbackRegistry'; +import type { Store, StoreGenerator, ItemRegistrationCallback } from './types'; -const registeredStoreGenerators = new Map(); -const hydratedStores = new Map(); +const storeGeneratorRegistry = new CallbackRegistry(); +const hydratedStoreRegistry = new CallbackRegistry(); export default { /** @@ -10,7 +11,7 @@ export default { */ register(storeGenerators: { [id: string]: StoreGenerator }): void { Object.keys(storeGenerators).forEach(name => { - if (registeredStoreGenerators.has(name)) { + if (storeGeneratorRegistry.has(name)) { console.warn('Called registerStore for store that is already registered', name); } @@ -20,7 +21,7 @@ export default { `for the store generator with key ${name}.`); } - registeredStoreGenerators.set(name, store); + storeGeneratorRegistry.set(name, store); }); }, @@ -32,11 +33,10 @@ export default { * @returns Redux Store, possibly hydrated */ getStore(name: string, throwIfMissing = true): Store | undefined { - if (hydratedStores.has(name)) { - return hydratedStores.get(name); - } + const store = hydratedStoreRegistry.get(name); + if (store) return store; - const storeKeys = Array.from(hydratedStores.keys()).join(', '); + const storeKeys = Array.from(hydratedStoreRegistry.getAll().keys()).join(', '); if (storeKeys.length === 0) { const msg = @@ -63,12 +63,10 @@ This can happen if you are server rendering and either: * @returns storeCreator with given name */ getStoreGenerator(name: string): StoreGenerator { - const registeredStoreGenerator = registeredStoreGenerators.get(name); - if (registeredStoreGenerator) { - return registeredStoreGenerator; - } + const generator = storeGeneratorRegistry.get(name); + if (generator) return generator; - const storeKeys = Array.from(registeredStoreGenerators.keys()).join(', '); + const storeKeys = Array.from(storeGeneratorRegistry.getAll().keys()).join(', '); throw new Error(`Could not find store registered with name '${name}'. Registered store ` + `names include [ ${storeKeys} ]. Maybe you forgot to register the store?`); }, @@ -79,14 +77,14 @@ This can happen if you are server rendering and either: * @param store (not the storeGenerator, but the hydrated store) */ setStore(name: string, store: Store): void { - hydratedStores.set(name, store); + hydratedStoreRegistry.set(name, store); }, /** * Internally used function to completely clear hydratedStores Map. */ clearHydratedStores(): void { - hydratedStores.clear(); + hydratedStoreRegistry.clear(); }, /** @@ -94,7 +92,7 @@ This can happen if you are server rendering and either: * @returns Map where key is the component name and values are the store generators. */ storeGenerators(): Map { - return registeredStoreGenerators; + return storeGeneratorRegistry.getAll(); }, /** @@ -102,6 +100,42 @@ This can happen if you are server rendering and either: * @returns Map where key is the component name and values are the hydrated stores. */ stores(): Map { - return hydratedStores; + return hydratedStoreRegistry.getAll(); + }, + + /** + * Register a callback to be called when a specific store is hydrated + * @param storeName Name of the store to watch for + * @param callback Function called with the store when hydrated + */ + onStoreHydrated(storeName: string, callback: ItemRegistrationCallback): void { + hydratedStoreRegistry.onItemRegistered(storeName, callback); + }, + + /** + * Used by components to get the hydrated store, waiting for it to be hydrated if necessary. + * @param name Name of the store to wait for + * @returns Promise that resolves with the Store once hydrated + */ + getOrWaitForStore(name: string): Promise { + return hydratedStoreRegistry.getOrWaitForItem(name); + }, + + /** + * Register a callback to be called when a specific store generator is registered + * @param storeName Name of the store generator to watch for + * @param callback Function called with the store generator when registered + */ + onStoreGeneratorRegistered(storeName: string, callback: ItemRegistrationCallback): void { + storeGeneratorRegistry.onItemRegistered(storeName, callback); + }, + + /** + * Used by components to get the store generator, waiting for it to be registered if necessary. + * @param name Name of the store generator to wait for + * @returns Promise that resolves with the StoreGenerator once registered + */ + getOrWaitForStoreGenerator(name: string): Promise { + return storeGeneratorRegistry.getOrWaitForItem(name); }, }; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 6997e17d6..1f5d92562 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -1,35 +1,15 @@ -import ReactDOM from 'react-dom'; -import type { ReactElement } from 'react'; -import type { - RailsContext, - ReactOnRails as ReactOnRailsType, - RegisteredComponent, - RenderFunction, - Root, -} from './types'; -import type { Context } from './context'; - -import createReactOutput from './createReactOutput'; -import { isServerRenderHash } from './isServerRenderResult'; -import reactHydrateOrRender from './reactHydrateOrRender'; -import { supportsRootApi } from './reactApis'; +import { reactOnRailsContext, type Context } from './context'; +import { + renderOrHydrateForceLoadedComponents, + renderOrHydrateAllComponents, + hydrateForceLoadedStores, + hydrateAllStores, + unmountAll, +} from './ClientSideRenderer'; /* eslint-disable @typescript-eslint/no-explicit-any */ declare global { - interface Window { - ReactOnRails: ReactOnRailsType; - __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; - roots: Root[]; - } - - namespace globalThis { - /* eslint-disable no-var,vars-on-top */ - var ReactOnRails: ReactOnRailsType; - var roots: Root[]; - /* eslint-enable no-var,vars-on-top */ - } - namespace Turbolinks { interface TurbolinksStatic { controller?: unknown; @@ -37,28 +17,13 @@ declare global { } } -declare const ReactOnRails: ReactOnRailsType; - -const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; - -function findContext(): Context { - if (typeof window.ReactOnRails !== 'undefined') { - return window; - } else if (typeof ReactOnRails !== 'undefined') { - return global; - } - - throw new Error(`\ -ReactOnRails is undefined in both global and window namespaces. - `); -} function debugTurbolinks(...msg: string[]): void { if (!window) { return; } - const context = findContext(); + const context = reactOnRailsContext(); if (context.ReactOnRails && context.ReactOnRails.option('traceTurbolinks')) { console.log('TURBO:', ...msg); } @@ -69,32 +34,13 @@ function turbolinksInstalled(): boolean { } function turboInstalled() { - const context = findContext(); + const context = reactOnRailsContext(); if (context.ReactOnRails) { return context.ReactOnRails.option('turbo') === true; } return false; } -function reactOnRailsHtmlElements(): HTMLCollectionOf { - return document.getElementsByClassName('js-react-on-rails-component'); -} - -function initializeStore(el: Element, context: Context, railsContext: RailsContext): void { - const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; - const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; - const storeGenerator = context.ReactOnRails.getStoreGenerator(name); - const store = storeGenerator(props, railsContext); - context.ReactOnRails.setStore(name, store); -} - -function forEachStore(context: Context, railsContext: RailsContext): void { - const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`); - for (let i = 0; i < els.length; i += 1) { - initializeStore(els[i], context, railsContext); - } -} - function turbolinksVersion5(): boolean { return (typeof Turbolinks.controller !== 'undefined'); } @@ -103,171 +49,15 @@ function turbolinksSupported(): boolean { return Turbolinks.supported; } -function delegateToRenderer( - componentObj: RegisteredComponent, - props: Record, - railsContext: RailsContext, - domNodeId: string, - trace: boolean, -): boolean { - const { name, component, isRenderer } = componentObj; - - if (isRenderer) { - if (trace) { - console.log(`\ -DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, - props, railsContext); - } - - (component as RenderFunction)(props, railsContext, domNodeId); - return true; - } - - return false; -} - -function domNodeIdForEl(el: Element): string { - return el.getAttribute('data-dom-id') || ''; -} - -/** - * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or - * delegates to a renderer registered by the user. - */ -function render(el: Element, context: Context, railsContext: RailsContext): void { - // This must match lib/react_on_rails/helper.rb - const name = el.getAttribute('data-component-name') || ''; - const domNodeId = domNodeIdForEl(el); - const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; - const trace = el.getAttribute('data-trace') === 'true'; - - try { - const domNode = document.getElementById(domNodeId); - if (domNode) { - const componentObj = context.ReactOnRails.getComponent(name); - if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { - return; - } - - // Hydrate if available and was server rendered - // @ts-expect-error potentially present if React 18 or greater - const shouldHydrate = !!(ReactDOM.hydrate || ReactDOM.hydrateRoot) && !!domNode.innerHTML; - - const reactElementOrRouterResult = createReactOutput({ - componentObj, - props, - domNodeId, - trace, - railsContext, - shouldHydrate, - }); - - if (isServerRenderHash(reactElementOrRouterResult)) { - throw new Error(`\ -You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} -You should return a React.Component always for the client side entry point.`); - } else { - const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate); - if (supportsRootApi) { - context.roots.push(rootOrElement as Root); - } - } - } - } catch (e: any) { - console.error(e.message); - e.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.` - throw e; - } -} - -function forEachReactOnRailsComponentRender(context: Context, railsContext: RailsContext): void { - const els = reactOnRailsHtmlElements(); - for (let i = 0; i < els.length; i += 1) { - render(els[i], context, railsContext); - } -} - -function parseRailsContext(): RailsContext | null { - const el = document.getElementById('js-react-on-rails-context'); - if (!el) { - // The HTML page will not have an element with ID 'js-react-on-rails-context' if there are no - // react on rails components - return null; - } - - if (!el.textContent) { - throw new Error('The HTML element with ID \'js-react-on-rails-context\' has no textContent'); - } - - return JSON.parse(el.textContent); -} - export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); - - const railsContext = parseRailsContext(); - - // If no react on rails components - if (!railsContext) return; - - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } - forEachStore(context, railsContext); - forEachReactOnRailsComponentRender(context, railsContext); -} - -export function reactOnRailsComponentLoaded(domId: string): void { - debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); - - const railsContext = parseRailsContext(); - - // If no react on rails components - if (!railsContext) return; - - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } - - const el = document.querySelector(`[data-dom-id=${domId}]`); - if (!el) return; - - render(el, context, railsContext); -} - -function unmount(el: Element): void { - const domNodeId = domNodeIdForEl(el); - const domNode = document.getElementById(domNodeId); - if (domNode === null) { - return; - } - try { - ReactDOM.unmountComponentAtNode(domNode); - } catch (e: any) { - console.info(`Caught error calling unmountComponentAtNode: ${e.message} for domNode`, - domNode, e); - } + hydrateAllStores(); + renderOrHydrateAllComponents(); } function reactOnRailsPageUnloaded(): void { debugTurbolinks('reactOnRailsPageUnloaded'); - if (supportsRootApi) { - const { roots } = findContext(); - - // If no react on rails components - if (!roots) return; - - for (const root of roots) { - root.unmount(); - } - } else { - const els = reactOnRailsHtmlElements(); - for (let i = 0; i < els.length; i += 1) { - unmount(els[i]); - } - } + unmountAll(); } function renderInit(): void { @@ -307,17 +97,6 @@ function isWindow(context: Context): context is Window { return (context as Window).document !== undefined; } -function onPageReady(callback: () => void) { - if (document.readyState === "complete") { - callback(); - } else { - document.addEventListener("readystatechange", function onReadyStateChange() { - onPageReady(callback); - document.removeEventListener("readystatechange", onReadyStateChange); - }); - } -} - export function clientStartup(context: Context): void { // Check if server rendering if (!isWindow(context)) { @@ -333,5 +112,12 @@ export function clientStartup(context: Context): void { // eslint-disable-next-line no-underscore-dangle, no-param-reassign context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true; - onPageReady(renderInit); + if (document.readyState !== 'complete') { + // force loaded components and stores are rendered and hydrated immediately + renderOrHydrateForceLoadedComponents(); + hydrateForceLoadedStores(); + + // Other components and stores are rendered and hydrated when the page is fully loaded + document.addEventListener('DOMContentLoaded', renderInit); + } } diff --git a/node_package/src/context.ts b/node_package/src/context.ts index 539f239ae..6683530fd 100644 --- a/node_package/src/context.ts +++ b/node_package/src/context.ts @@ -1,3 +1,18 @@ +import type { ReactOnRails as ReactOnRailsType } from './types'; + +declare global { + interface Window { + ReactOnRails: ReactOnRailsType; + __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; + } + + namespace globalThis { + /* eslint-disable no-var,vars-on-top */ + var ReactOnRails: ReactOnRailsType; + /* eslint-enable no-var,vars-on-top */ + } +} + export type Context = Window | typeof globalThis; /** @@ -8,3 +23,12 @@ export default function context(this: void): Context | void { ((typeof global !== 'undefined') && global) || this; } + + +export function reactOnRailsContext(): Context { + const ctx = context(); + if (ctx === undefined || typeof ctx.ReactOnRails === 'undefined') { + throw new Error('ReactOnRails is undefined in both global and window namespaces.'); + } + return ctx; +} diff --git a/node_package/src/loadReactClientManifest.ts b/node_package/src/loadReactClientManifest.ts new file mode 100644 index 000000000..e79181912 --- /dev/null +++ b/node_package/src/loadReactClientManifest.ts @@ -0,0 +1,17 @@ +import path from 'path'; +import fs from 'fs'; + +const loadedReactClientManifests = new Map(); + +export default function loadReactClientManifest(reactClientManifestFileName: string) { + // React client manifest is uploaded to node renderer as an asset. + // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. + // Thus, the __dirname of this code is where we can find the manifest file. + const manifestPath = path.resolve(__dirname, reactClientManifestFileName); + if (!loadedReactClientManifests.has(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + loadedReactClientManifests.set(manifestPath, manifest); + } + + return loadedReactClientManifests.get(manifestPath)!; +} diff --git a/node_package/src/registerServerComponent.ts b/node_package/src/registerServerComponent.ts new file mode 100644 index 000000000..9470766a7 --- /dev/null +++ b/node_package/src/registerServerComponent.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import ReactOnRails from './ReactOnRails'; +import RSCClientRoot from './RSCClientRoot'; +import { RegisterServerComponentOptions } from './types'; + +const registerServerComponent = (options: RegisterServerComponentOptions, ...componentNames: string[]) => { + const componentsWrappedInRSCClientRoot = componentNames.reduce( + (acc, name) => ({ + ...acc, + [name]: () => React.createElement(RSCClientRoot, { + componentName: name, + rscRenderingUrlPath: options.rscRenderingUrlPath + }) + }), + {} + ); + ReactOnRails.register(componentsWrappedInRSCClientRoot); +}; + +export default registerServerComponent; diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 8e31b8ca2..7e76a5140 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -17,7 +17,7 @@ const stringToStream = (str: string): Readable => { return stream; }; -const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => { +export const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => { const consoleHistory = console.history; let previouslyReplayedConsoleMessages = 0; @@ -105,7 +105,15 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: return readableStream; } -const streamServerRenderedReactComponent = (options: RenderParams): Readable => { +type StreamRenderer = ( + reactElement: ReactElement, + options: P, +) => T; + +export const streamServerRenderedComponent = ( + options: P, + renderStrategy: StreamRenderer +): T => { const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; try { @@ -124,7 +132,7 @@ const streamServerRenderedReactComponent = (options: RenderParams): Readable => throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); } - return streamRenderReactComponent(reactRenderingResult, options); + return renderStrategy(reactRenderingResult, options); } catch (e) { if (throwJsErrors) { throw e; @@ -133,8 +141,10 @@ const streamServerRenderedReactComponent = (options: RenderParams): Readable => const error = convertToError(e); const htmlResult = handleError({ e: error, name: componentName, serverSide: true }); const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null })); - return stringToStream(jsonResult); + return stringToStream(jsonResult) as T; } }; +const streamServerRenderedReactComponent = (options: RenderParams): Readable => streamServerRenderedComponent(options, streamRenderReactComponent); + export default streamServerRenderedReactComponent; diff --git a/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts b/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts new file mode 100644 index 000000000..4fae6cf46 --- /dev/null +++ b/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts @@ -0,0 +1,40 @@ +export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream) { + return new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + let { value, done } = await reader.read(); + while (!done) { + const decodedValue = decoder.decode(value); + const jsonChunks = decodedValue.split('\n') + .filter(line => line.trim() !== '') + .map((line) => { + try { + return JSON.parse(line); + } catch (error) { + console.error('Error parsing JSON:', line, error); + throw error; + } + }); + + for (const jsonChunk of jsonChunks) { + const { html, consoleReplayScript } = jsonChunk; + controller.enqueue(encoder.encode(html)); + + const replayConsoleCode = consoleReplayScript?.trim().replace(/^/, '').replace(/<\/script>$/, ''); + if (replayConsoleCode?.trim() !== '') { + const scriptElement = document.createElement('script'); + scriptElement.textContent = replayConsoleCode; + document.body.appendChild(scriptElement); + } + } + + // eslint-disable-next-line no-await-in-loop + ({ value, done } = await reader.read()); + } + controller.close(); + } + }); +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index f38b446a1..0817f9ccc 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,5 +1,8 @@ +// eslint-disable-next-line spaced-comment +/// + import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; -import type { Readable, PassThrough } from 'stream'; +import type { Readable } from 'stream'; // Don't import redux just for the type definitions // See https://github.com/shakacode/react_on_rails/issues/1321 @@ -104,6 +107,12 @@ export interface RegisteredComponent { isRenderer: boolean; } +export interface RegisterServerComponentOptions { + rscRenderingUrlPath: string; +} + +export type ItemRegistrationCallback = (component: T) => void; + interface Params { props?: Record; railsContext?: RailsContext; @@ -117,6 +126,10 @@ export interface RenderParams extends Params { renderingReturnsPromises: boolean; } +export interface RSCRenderParams extends RenderParams { + reactClientManifestFileName: string; +} + export interface CreateParams extends Params { componentObj: RegisteredComponent; shouldHydrate?: boolean; @@ -155,10 +168,13 @@ export interface ReactOnRails { registerStore(stores: { [id: string]: StoreGenerator }): void; registerStoreGenerators(storeGenerators: { [id: string]: StoreGenerator }): void; getStore(name: string, throwIfMissing?: boolean): Store | undefined; + getOrWaitForStore(name: string): Promise; + getOrWaitForStoreGenerator(name: string): Promise; setOptions(newOptions: {traceTurbolinks: boolean}): void; reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; reactOnRailsPageLoaded(): void; reactOnRailsComponentLoaded(domId: string): void; + reactOnRailsStoreLoaded(storeName: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; option(key: string): string | number | boolean | undefined; @@ -169,9 +185,10 @@ export interface ReactOnRails { name: string, props: Record, domNodeId: string, hydrate: boolean ): RenderReturnType; getComponent(name: string): RegisteredComponent; + getOrWaitForComponent(name: string): Promise; serverRenderReactComponent(options: RenderParams): null | string | Promise; streamServerRenderedReactComponent(options: RenderParams): Readable; - serverRenderRSCReactComponent(options: RenderParams): PassThrough; + serverRenderRSCReactComponent(options: RSCRenderParams): Readable; handleError(options: ErrorOptions): string | undefined; buildConsoleReplay(): string; registeredComponents(): Map; diff --git a/node_package/src/utils.ts b/node_package/src/utils.ts new file mode 100644 index 000000000..352905a17 --- /dev/null +++ b/node_package/src/utils.ts @@ -0,0 +1,8 @@ +// Override the fetch function to make it easier to test and for future use +const customFetch = (...args: Parameters) => { + const res = fetch(...args); + return res; +} + +// eslint-disable-next-line import/prefer-default-export +export { customFetch as fetch }; diff --git a/node_package/tests/RSCClientRoot.test.jsx b/node_package/tests/RSCClientRoot.test.jsx new file mode 100644 index 000000000..952cde6ff --- /dev/null +++ b/node_package/tests/RSCClientRoot.test.jsx @@ -0,0 +1,127 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable import/first */ +/** + * @jest-environment jsdom + */ + +// Mock webpack require system for RSC +window.__webpack_require__ = jest.fn(); +window.__webpack_chunk_load__ = jest.fn(); + +import * as React from 'react'; +import { enableFetchMocks } from 'jest-fetch-mock'; +import { render, waitFor, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import path from 'path'; +import fs from 'fs'; +import { createNodeReadableStream } from './testUtils'; + +import RSCClientRoot, { resetRenderCache } from '../src/RSCClientRoot'; + +enableFetchMocks(); + +// TODO: Remove this once we made these tests compatible with React 19 +(process.env.USE_REACT_18 ? describe : describe.skip)('RSCClientRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.resetModules(); + resetRenderCache(); + }); + + it('throws error when React.use is not defined', () => { + jest.mock('react', () => ({ + ...jest.requireActual('react'), + use: undefined, + })); + + expect(() => { + // Re-import to trigger the check + jest.requireActual('../src/RSCClientRoot'); + }).toThrow('React.use is not defined'); + }); + + const mockRSCRequest = (rscRenderingUrlPath = 'rsc-render') => { + const chunksDirectory = path.join( + __dirname, + 'fixtures', + 'rsc-payloads', + 'simple-shell-with-async-component', + ); + const chunk1 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk1.json'), 'utf8')); + const chunk2 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk2.json'), 'utf8')); + + const { stream, push } = createNodeReadableStream(); + window.fetchMock.mockResolvedValue(new Response(stream)); + + const props = { + componentName: 'TestComponent', + rscRenderingUrlPath, + }; + + const { rerender } = render(); + + return { + rerender: () => rerender(), + pushFirstChunk: () => push(JSON.stringify(chunk1)), + pushSecondChunk: () => push(JSON.stringify(chunk2)), + pushCustomChunk: (chunk) => push(chunk), + endStream: () => push(null), + }; + }; + + it('fetches and caches component data', async () => { + const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest(); + + expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent'); + expect(window.fetch).toHaveBeenCalledTimes(1); + expect(screen.queryByText('StaticServerComponent')).not.toBeInTheDocument(); + + pushFirstChunk(); + await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument()); + expect(screen.getByText('Loading AsyncComponent...')).toBeInTheDocument(); + expect(screen.queryByText('AsyncComponent')).not.toBeInTheDocument(); + + pushSecondChunk(); + endStream(); + await waitFor(() => expect(screen.getByText('AsyncComponent')).toBeInTheDocument()); + expect(screen.queryByText('Loading AsyncComponent...')).not.toBeInTheDocument(); + + // Second render - should use cache + rerender(); + + expect(screen.getByText('AsyncComponent')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledTimes(1); + }); + + it('replays console logs', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest(); + + pushFirstChunk(); + await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at first chunk')); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + pushSecondChunk(); + await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at second chunk')); + endStream(); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + // On rerender, console logs should not be replayed again + rerender(); + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('strips leading and trailing slashes from rscRenderingUrlPath', async () => { + const { pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest('/rsc-render/'); + + pushFirstChunk(); + pushSecondChunk(); + endStream(); + + await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent')); + expect(window.fetch).toHaveBeenCalledTimes(1); + + await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument()); + }); +}); diff --git a/node_package/tests/emptyForTesting.js b/node_package/tests/emptyForTesting.js new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/node_package/tests/emptyForTesting.js @@ -0,0 +1 @@ +export default {}; diff --git a/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk1.json b/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk1.json new file mode 100644 index 000000000..5ea689406 --- /dev/null +++ b/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk1.json @@ -0,0 +1,6 @@ +{ + "html": "1:\"$Sreact.suspense\"\n0:D{\"name\":\"StaticServerComponent\",\"env\":\"Server\"}\n2:D{\"name\":\"AsyncComponent\",\"env\":\"Server\"}\n0:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h1\",null,{\"children\":\"StaticServerComponent\"}],[\"$\",\"p\",null,{\"children\":\"This is a static server component\"}],[\"$\",\"$1\",null,{\"fallback\":[\"$\",\"div\",null,{\"children\":\"Loading AsyncComponent...\"}],\"children\":\"$L2\"}]]}]\n", + "consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at first chunk\"]);\n\u003c/script\u003e", + "hasErrors": false, + "isShellReady": true +} diff --git a/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk2.json b/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk2.json new file mode 100644 index 000000000..1737a3859 --- /dev/null +++ b/node_package/tests/fixtures/rsc-payloads/simple-shell-with-async-component/chunk2.json @@ -0,0 +1,6 @@ +{ + "html": "2:[\"$\",\"div\",null,{\"children\":\"AsyncComponent\"}]\n", + "consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at second chunk\"]);\n\u003c/script\u003e", + "hasErrors": false, + "isShellReady": true +} diff --git a/node_package/tests/jest.setup.js b/node_package/tests/jest.setup.js index 24ae0b976..5b4acefa3 100644 --- a/node_package/tests/jest.setup.js +++ b/node_package/tests/jest.setup.js @@ -13,6 +13,33 @@ if (typeof window !== 'undefined' && typeof window.MessageChannel !== 'undefined if (typeof window !== 'undefined') { // eslint-disable-next-line global-require const { TextEncoder, TextDecoder } = require('util'); + // eslint-disable-next-line global-require + const { Readable } = require('stream'); + // eslint-disable-next-line global-require + const { ReadableStream, ReadableStreamDefaultReader } = require('stream/web'); + + // Mock the fetch function to return a ReadableStream instead of Node's Readable stream + // This matches browser behavior where fetch responses have ReadableStream bodies + // Node's fetch and polyfills like jest-fetch-mock return Node's Readable stream, + // so we convert it to a web-standard ReadableStream for consistency + // Note: Node's Readable stream exists in node 'stream' built-in module, can be imported as `import { Readable } from 'stream'` + jest.mock('../src/utils', () => ({ + ...jest.requireActual('../src/utils'), + fetch: (...args) => + jest + .requireActual('../src/utils') + .fetch(...args) + .then((res) => { + const originalBody = res.body; + if (originalBody instanceof Readable) { + Object.defineProperty(res, 'body', { + value: Readable.toWeb(originalBody), + }); + } + return res; + }), + })); + global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; @@ -32,4 +59,6 @@ if (typeof window !== 'undefined') { }, }; }); + global.ReadableStream = ReadableStream; + global.ReadableStreamDefaultReader = ReadableStreamDefaultReader; } diff --git a/node_package/tests/testUtils.js b/node_package/tests/testUtils.js new file mode 100644 index 000000000..77e7dd577 --- /dev/null +++ b/node_package/tests/testUtils.js @@ -0,0 +1,33 @@ +import { Readable } from 'stream'; + +/** + * Creates a Node.js Readable stream with external push capability. + * Pusing a null or undefined chunk will end the stream. + * @returns {{ + * stream: Readable, + * push: (chunk: any) => void + * }} Object containing the stream and push function + */ +// eslint-disable-next-line import/prefer-default-export +export const createNodeReadableStream = () => { + const pendingChunks = []; + let pushFn; + const stream = new Readable({ + read() { + pushFn = this.push.bind(this); + if (pendingChunks.length > 0) { + pushFn(pendingChunks.shift()); + } + }, + }); + + const push = (chunk) => { + if (pushFn) { + pushFn(chunk); + } else { + pendingChunks.push(chunk); + } + }; + + return { stream, push }; +}; diff --git a/node_package/tests/utils.test.js b/node_package/tests/utils.test.js new file mode 100644 index 000000000..ab5e6eb30 --- /dev/null +++ b/node_package/tests/utils.test.js @@ -0,0 +1,44 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; + +import { fetch } from '../src/utils'; +import { createNodeReadableStream } from './testUtils'; + +enableFetchMocks(); + +describe('fetch', () => { + it('streams body as ReadableStream', async () => { + // create Readable stream that emits 5 chunks with 10ms delay between each chunk + const { stream, push } = createNodeReadableStream(); + let n = 0; + const intervalId = setInterval(() => { + n += 1; + push(`chunk${n}`); + if (n === 5) { + clearInterval(intervalId); + push(null); + } + }, 10); + + global.fetchMock.mockResolvedValue(new Response(stream)); + + await fetch('/test').then(async (response) => { + console.log(response.body); + const { body } = response; + expect(body).toBeInstanceOf(ReadableStream); + + const reader = body.getReader(); + const chunks = []; + const decoder = new TextDecoder(); + let { done, value } = await reader.read(); + while (!done) { + chunks.push(decoder.decode(value)); + // eslint-disable-next-line no-await-in-loop + ({ done, value } = await reader.read()); + } + expect(chunks).toEqual(['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']); + + // expect global.fetch to be called one time + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/node_package/types/react-server-dom-webpack.d.ts b/node_package/types/react-server-dom-webpack.d.ts index 652fbd9ea..6f422e9c6 100644 --- a/node_package/types/react-server-dom-webpack.d.ts +++ b/node_package/types/react-server-dom-webpack.d.ts @@ -8,10 +8,38 @@ declare module 'react-server-dom-webpack/node-loader' { source: string; } - // eslint-disable-next-line import/prefer-default-export export function load( url: string, context: null | object, defaultLoad: () => Promise ): Promise; } + +declare module 'react-server-dom-webpack/server.node' { + export interface Options { + environmentName?: string; + onError?: (error: unknown) => void; + onPostpone?: (reason: string) => void; + identifierPrefix?: string; + } + + export interface PipeableStream { + abort(reason: unknown): void; + pipe(destination: Writable): Writable; + } + + // Note: ReactClientValue is likely what React uses internally for RSC + // We're using 'unknown' here as it's the most accurate type we can use + // without accessing React's internal types + export function renderToPipeableStream( + model: unknown, + webpackMap: { [key: string]: unknown }, + options?: Options + ): PipeableStream; +} + +declare module 'react-server-dom-webpack/client' { + export const createFromFetch: (promise: Promise) => Promise; + + export const createFromReadableStream: (stream: ReadableStream) => Promise; +} diff --git a/package.json b/package.json index 59a94008f..dc2b11156 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "rsc-server": "./node_package/lib/ReactOnRailsRSC.js", "default": "./node_package/lib/ReactOnRails.js" }, - "./RSCWebpackLoader": "./node_package/lib/RSCWebpackLoader.js" + "./RSCWebpackLoader": "./node_package/lib/RSCWebpackLoader.js", + "./registerServerComponent": "./node_package/lib/registerServerComponent.js" }, "directories": { "doc": "docs" @@ -16,6 +17,9 @@ "devDependencies": { "@babel/core": "^7.20.12", "@babel/preset-env": "^7.20.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", "@types/node": "^20.17.16", "@types/react": "^18.3.18", @@ -25,6 +29,7 @@ "@typescript-eslint/parser": "^6.18.1", "concurrently": "^8.2.2", "create-react-class": "^15.7.0", + "cross-fetch": "^4.1.0", "eslint": "^7.32.0", "eslint-config-prettier": "^7.0.0", "eslint-config-shakacode": "^16.0.1", @@ -34,28 +39,33 @@ "eslint-plugin-react": "^7.33.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "jsdom": "^22.1.0", "knip": "^5.43.1", "nps": "^5.9.3", "prettier": "^2.8.8", "prop-types": "^15.8.1", "react": "^19.0.0", + "react-18": "npm:react@18.3.0-canary-670811593-20240322", "react-dom": "^19.0.0", - "react-server-dom-webpack": "^19.0.0", + "react-dom-18": "npm:react-dom@18.3.0-canary-670811593-20240322", + "react-server-dom-webpack": "18.3.0-canary-670811593-20240322", "redux": "^4.2.1", "ts-jest": "^29.2.5", "typescript": "^5.6.2" }, - "dependencies": { - }, + "dependencies": {}, "peerDependencies": { "react": ">= 16", - "react-dom": ">= 16" + "react-dom": ">= 16", + "react-server-dom-webpack": ">= 18.3.0-canary-670811593-20240322" }, "files": [ "node_package/lib" ], "scripts": { "test": "jest node_package/tests", + "test:react-18": "USE_REACT_18=true jest node_package/tests/RSCClientRoot.test.jsx", "clean": "rm -rf node_package/lib", "start": "nps", "prepack": "nps build.prepack", diff --git a/spec/dummy/config/webpack/clientWebpackConfig.js b/spec/dummy/config/webpack/clientWebpackConfig.js index 823551d4a..3fb504c62 100644 --- a/spec/dummy/config/webpack/clientWebpackConfig.js +++ b/spec/dummy/config/webpack/clientWebpackConfig.js @@ -9,6 +9,10 @@ const configureClient = () => { // client config is going to try to load chunks. delete clientConfig.entry['server-bundle']; + // clientConfig.resolve.fallback = { + // ...clientConfig.resolve.fallback, + // 'process/browser': false, + // }; return clientConfig; }; diff --git a/spec/dummy/spec/packs_generator_spec.rb b/spec/dummy/spec/packs_generator_spec.rb index f7988b337..ca738c039 100644 --- a/spec/dummy/spec/packs_generator_spec.rb +++ b/spec/dummy/spec/packs_generator_spec.rb @@ -291,6 +291,304 @@ def stub_packer_source_path(packer_source_path:, component_name:) allow(ReactOnRails::PackerUtils).to receive(:packer_source_path) .and_return("#{packer_source_path}/components/#{component_name}") end + + describe "#first_js_statement_in_code" do + subject { described_class.instance.send(:first_js_statement_in_code, content) } + + context "with simple content" do + let(:content) { "const x = 1;" } + + it { is_expected.to eq "const x = 1;" } + end + + context "with single-line comments" do + let(:content) do + <<~JS + // First comment + // Second comment + const x = 1; + const y = 2; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + + context "with multi-line comments" do + let(:content) do + <<~JS + /* This is a + multiline comment */ + const x = 1; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + + context "with mixed comments" do + let(:content) do + <<~JS + // Single line comment + /* Multi-line + comment */ + // Another single line + const x = 1; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + + context "with mixed comments and whitespace" do + let(:content) do + <<~JS + + // First comment + #{' '} + /* + multiline comment + */ + + // comment with preceding whitespace + + // Another single line + + + const x = 1; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + + context "with only comments" do + let(:content) do + <<~JS + // Just a comment + /* Another comment */ + JS + end + + it { is_expected.to eq "" } + end + + context "with comment at end of file" do + let(:content) { "const x = 1;\n// Final comment" } + + it { is_expected.to eq "const x = 1;" } + end + + context "with empty content" do + let(:content) { "" } + + it { is_expected.to eq "" } + end + + context "with only whitespace" do + let(:content) { " \n \t " } + + it { is_expected.to eq "" } + end + + context "with statement containing comment-like strings" do + let(:content) { 'const url = "http://example.com"; // Real comment' } + + # it returns the statement starting from non-space character until the next line even if it contains a comment + it { is_expected.to eq 'const url = "http://example.com"; // Real comment' } + end + + context "with unclosed multi-line comment" do + let(:content) do + <<~JS + /* This comment + never ends + const x = 1; + JS + end + + it { is_expected.to eq "" } + end + + context "with nested comments" do + let(:content) do + <<~JS + // /* This is still a single line comment */ + const x = 1; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + + context "with one line comment with no space after //" do + let(:content) { "//const x = 1;" } + + it { is_expected.to eq "" } + end + + context "with one line comment with no new line after it" do + let(:content) { "// const x = 1" } + + it { is_expected.to eq "" } + end + + context "with string directive" do + context "when on top of the file" do + let(:content) do + <<~JS + "use client"; + // const x = 1 + const b = 2; + JS + end + + it { is_expected.to eq '"use client";' } + end + + context "when on top of the file and one line comment" do + let(:content) { '"use client"; // const x = 1' } + + it { is_expected.to eq '"use client"; // const x = 1' } + end + + context "when after some one-line comments" do + let(:content) do + <<~JS + // First comment + // Second comment + "use client"; + JS + end + + it { is_expected.to eq '"use client";' } + end + + context "when after some multi-line comments" do + let(:content) do + <<~JS + /* First comment */ + /* + multiline comment + */ + "use client"; + JS + end + + it { is_expected.to eq '"use client";' } + end + + context "when after some mixed comments" do + let(:content) do + <<~JS + // First comment + /* + multiline comment + */ + "use client"; + JS + end + + it { is_expected.to eq '"use client";' } + end + + context "when after any non-comment code" do + let(:content) do + <<~JS + // First comment + const x = 1; + "use client"; + JS + end + + it { is_expected.to eq "const x = 1;" } + end + end + end + + describe "#client_entrypoint?" do + subject { described_class.instance.send(:client_entrypoint?, "dummy_path.js") } + + before do + allow(File).to receive(:read).with("dummy_path.js").and_return(content) + end + + context "when file has 'use client' directive" do + context "with double quotes" do + let(:content) { '"use client";' } + + it { is_expected.to be true } + end + + context "with single quotes" do + let(:content) { "'use client';" } + + it { is_expected.to be true } + end + + context "without semicolon" do + let(:content) { '"use client"' } + + it { is_expected.to be true } + end + + context "with trailing whitespace" do + let(:content) { '"use client" ' } + + it { is_expected.to be true } + end + + context "with comments before directive" do + let(:content) do + <<~JS + // some comment + /* multi-line + comment */ + "use client"; + JS + end + + it { is_expected.to be true } + end + end + + context "when file does not have 'use client' directive" do + context "with empty file" do + let(:content) { "" } + + it { is_expected.to be false } + end + + context "with regular JS code" do + let(:content) { "const x = 1;" } + + it { is_expected.to be false } + end + + context "with 'use client' in a comment" do + let(:content) { "// 'use client'" } + + it { is_expected.to be false } + end + + context "with 'use client' in middle of file" do + let(:content) do + <<~JS + const x = 1; + "use client"; + JS + end + + it { is_expected.to be false } + end + + context "with similar but incorrect directive" do + let(:content) { "use client;" } # without quotes + + it { is_expected.to be false } + end + end + end end # rubocop:enable Metrics/BlockLength end diff --git a/spec/react_on_rails/packer_utils_spec.rb b/spec/react_on_rails/packer_utils_spec.rb index b7bd4f2e5..dc773b2a5 100644 --- a/spec/react_on_rails/packer_utils_spec.rb +++ b/spec/react_on_rails/packer_utils_spec.rb @@ -35,5 +35,46 @@ module ReactOnRails expect(described_class.shakapacker_version_requirement_met?(minimum_version)).to be(true) end end + + describe ".asset_uri_from_packer" do + let(:asset_name) { "test-asset.js" } + let(:public_output_path) { "/path/to/public/webpack/dev" } + + context "when dev server is running" do + before do + allow(described_class.packer).to receive(:dev_server).and_return( + instance_double( + ReactOnRails::PackerUtils.packer::DevServer, + running?: true, + protocol: "http", + host_with_port: "localhost:3035" + ) + ) + + allow(described_class.packer).to receive_message_chain("config.public_output_path") + .and_return(Pathname.new(public_output_path)) + allow(described_class.packer).to receive_message_chain("config.public_path") + .and_return(Pathname.new("/path/to/public")) + end + + it "returns asset URL with dev server path" do + expected_url = "http://localhost:3035/webpack/dev/test-asset.js" + expect(described_class.asset_uri_from_packer(asset_name)).to eq(expected_url) + end + end + + context "when dev server is not running" do + before do + allow(described_class.packer).to receive_message_chain("dev_server.running?").and_return(false) + allow(described_class.packer).to receive_message_chain("config.public_output_path") + .and_return(Pathname.new(public_output_path)) + end + + it "returns file path to the asset" do + expected_path = File.join(public_output_path, asset_name) + expect(described_class.asset_uri_from_packer(asset_name)).to eq(expected_path) + end + end + end end end diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index d87873ef0..eecc7d009 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -493,6 +493,73 @@ def mock_dev_server_running end end end + + describe ".react_client_manifest_file_path" do + before do + described_class.instance_variable_set(:@react_client_manifest_path, nil) + allow(ReactOnRails.configuration).to receive(:react_client_manifest_file) + .and_return("react-client-manifest.json") + end + + after do + described_class.instance_variable_set(:@react_client_manifest_path, nil) + end + + context "when using packer" do + let(:public_output_path) { "/path/to/public/webpack/dev" } + + before do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(true) + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("config.public_output_path") + .and_return(Pathname.new(public_output_path)) + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("config.public_path") + .and_return(Pathname.new("/path/to/public")) + end + + context "when dev server is running" do + before do + allow(ReactOnRails::PackerUtils.packer).to receive(:dev_server).and_return( + instance_double( + Object.const_get(ReactOnRails::PackerUtils.packer_type.capitalize)::DevServer, + running?: true, + protocol: "http", + host_with_port: "localhost:3035" + ) + ) + end + + it "returns manifest URL with dev server path" do + expected_url = "http://localhost:3035/webpack/dev/react-client-manifest.json" + expect(described_class.react_client_manifest_file_path).to eq(expected_url) + end + end + + context "when dev server is not running" do + before do + allow(ReactOnRails::PackerUtils.packer).to receive_message_chain("dev_server.running?") + .and_return(false) + end + + it "returns file path to the manifest" do + expected_path = File.join(public_output_path, "react-client-manifest.json") + expect(described_class.react_client_manifest_file_path).to eq(expected_path) + end + end + end + + context "when not using packer" do + before do + allow(ReactOnRails::PackerUtils).to receive(:using_packer?).and_return(false) + allow(described_class).to receive(:generated_assets_full_path) + .and_return("/path/to/generated/assets") + end + + it "returns joined path with generated_assets_full_path" do + expect(described_class.react_client_manifest_file_path) + .to eq("/path/to/generated/assets/react-client-manifest.json") + end + end + end end end # rubocop:enable Metrics/ModuleLength, Metrics/BlockLength diff --git a/tsconfig.json b/tsconfig.json index f6e0b9bd7..e0c8fae62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "outDir": "node_package/lib", "strict": true, "incremental": true, - "target": "es5" + "target": "es5", + "typeRoots": ["./node_modules/@types", "./node_package/types"] }, "include": ["node_package/src/**/*", "node_package/types/**/*"], "exclude": ["node_package/src/RSCWebpackLoader.js"] diff --git a/yarn.lock b/yarn.lock index 8e96e32b4..259ea9c78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.4.0": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.1.tgz#2447a230bfe072c1659e6815129c03cf170710e3" + integrity sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ== + "@ampproject/remapping@^2.1.0", "@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" @@ -1077,6 +1082,13 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== +"@babel/runtime@^7.12.5": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.21.0": version "7.22.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438" @@ -1588,11 +1600,50 @@ ignore "^5.1.8" p-map "^4.0.0" +"@testing-library/dom@^10.4.0": + version "10.4.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" + integrity sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.6.3": + version "6.6.3" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2" + integrity sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + lodash "^4.17.21" + redent "^3.0.0" + +"@testing-library/react@^16.2.0": + version "16.2.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.2.0.tgz#c96126ee01a49cdb47175721911b4a9432afc601" + integrity sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.1.14": version "7.20.0" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.0.tgz#61bc5a4cae505ce98e1e36c5445e4bee060d8891" @@ -1968,13 +2019,18 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@^5.3.0: +aria-query@5.3.0, aria-query@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== dependencies: dequal "^2.0.3" +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + array-buffer-byte-length@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead" @@ -2355,6 +2411,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -2363,7 +2427,7 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.2, chalk@^4.1.2: +chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -2530,6 +2594,20 @@ create-react-class@^15.7.0: loose-envify "^1.3.1" object-assign "^4.1.1" +cross-fetch@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" + integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== + dependencies: + node-fetch "^2.7.0" + +cross-fetch@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" + integrity sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw== + dependencies: + node-fetch "^2.7.0" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -2539,6 +2617,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + cssom@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" @@ -2556,6 +2639,13 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +cssstyle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a" + integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg== + dependencies: + rrweb-cssom "^0.6.0" + csstype@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" @@ -2575,6 +2665,15 @@ data-urls@^3.0.2: whatwg-mimetype "^3.0.0" whatwg-url "^11.0.0" +data-urls@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4" + integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.0" + date-fns@^2.30.0: version "2.30.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" @@ -2613,6 +2712,11 @@ decimal.js@^10.4.2: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decimal.js@^10.4.3: + version "10.5.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22" + integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw== + dedent@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" @@ -2714,6 +2818,16 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -2782,7 +2896,7 @@ enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" -entities@^4.4.0: +entities@^4.4.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -4252,6 +4366,14 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" @@ -4547,6 +4669,35 @@ jsdom@^20.0.0: ws "^8.11.0" xml-name-validator "^4.0.0" +jsdom@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" + integrity sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw== + dependencies: + abab "^2.0.6" + cssstyle "^3.0.0" + data-urls "^4.0.0" + decimal.js "^10.4.3" + domexception "^4.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.4" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^12.0.1" + ws "^8.13.0" + xml-name-validator "^4.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -4741,6 +4892,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + make-dir@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.0.2.tgz#04a1acbf22221e1d6ef43559f43e05a90dbb4392" @@ -4813,6 +4969,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + minimatch@9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" @@ -4864,6 +5025,13 @@ neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +node-fetch@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -4909,6 +5077,11 @@ nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== +nwsapi@^2.2.4: + version "2.2.16" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.16.tgz#177760bba02c351df1d2644e220c31dfec8cdb43" + integrity sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ== + object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -5152,6 +5325,13 @@ parse5@^7.0.0, parse5@^7.1.1: dependencies: entities "^4.4.0" +parse5@^7.1.2: + version "7.2.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a" + integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== + dependencies: + entities "^4.5.0" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -5251,6 +5431,15 @@ prettier@^2.8.8: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" @@ -5272,6 +5461,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + prompts@^2.0.1: version "2.3.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068" @@ -5306,6 +5500,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +punycode@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + pure-rand@^6.0.0: version "6.0.4" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" @@ -5321,6 +5520,18 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +"react-18@npm:react@18.3.0-canary-670811593-20240322": + version "18.3.0-canary-670811593-20240322" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.0-canary-670811593-20240322.tgz#3735250b45468d313ed36121324452bb5a732e9b" + integrity sha512-EI6+q3tOT+0z4OkB2sz842Ra/n/yz7b3jOJhSK1HQwi4Ng29VJzLGngWmSuxQ94YfdE3EBhpUKDfgNgzoKM9Vg== + +"react-dom-18@npm:react-dom@18.3.0-canary-670811593-20240322": + version "18.3.0-canary-670811593-20240322" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.0-canary-670811593-20240322.tgz#ac677b164fd83050272bf985e740ed4ca65337be" + integrity sha512-AHxCnyDzZueXIHY4WA2Uba1yaL7/vbjhO3D3TWPQeruKD5MwgD0/xExZi0T104gBr6Thv6MEsLSxFjBAHhHKKg== + dependencies: + scheduler "0.24.0-canary-670811593-20240322" + react-dom@^19.0.0: version "19.0.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" @@ -5333,19 +5544,23 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-server-dom-webpack@^19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-server-dom-webpack/-/react-server-dom-webpack-19.0.0.tgz#c60819b6cb54e317e675ddc0c5959ff915b789d0" - integrity sha512-hLug9KEXLc8vnU9lDNe2b2rKKDaqrp5gNiES4uyu2Up3FZfZJZmdwLFXlWzdA9gTB/6/cWduSB2K1Lfag2pSvw== +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" - webpack-sources "^3.2.0" react@^19.0.0: version "19.0.0" @@ -5357,6 +5572,14 @@ readline-sync@^1.4.7: resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw== +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + redux@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" @@ -5527,6 +5750,11 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + run-parallel@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef" @@ -5582,6 +5810,11 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" +scheduler@0.24.0-canary-670811593-20240322: + version "0.24.0-canary-670811593-20240322" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.24.0-canary-670811593-20240322.tgz#45c5c45f18a127ab4e3c805dd466bc231b20adf3" + integrity sha512-IGX6Fq969h1L0X7jV0sJ/EdI4fr+mRetbBNJl55nn+/RsCuQSVwgKnZG6Q3NByixDNbkRI8nRmWuhOm8NQowGQ== + scheduler@^0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" @@ -5898,6 +6131,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-5.0.1.tgz#0d8b7d01b23848ed7dbdf4baaaa31a8250d8cfa0" @@ -6013,6 +6253,18 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -6237,16 +6489,16 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -webpack-sources@^3.2.0: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== - whatwg-encoding@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" @@ -6267,6 +6519,22 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^12.0.0, whatwg-url@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c" + integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.1, which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -6382,6 +6650,11 @@ ws@^8.11.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== +ws@^8.13.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"