diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 2a468c413..5d1549bb4 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_RSC_RENDERING_URL = "rsc/".freeze def self.configuration @configuration ||= Configuration.new( @@ -41,7 +42,9 @@ def self.configuration make_generated_server_bundle_the_entrypoint: false, defer_generated_component_packs: true, # forces the loading of React components - force_load: false + force_load: false, + auto_load_server_components: true, + rsc_rendering_url: DEFAULT_RSC_RENDERING_URL ) end @@ -56,7 +59,7 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :defer_generated_component_packs, - :force_load + :force_load, :auto_load_server_components, :rsc_rendering_url # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -71,7 +74,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender same_bundle_for_client_and_server: nil, i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, - components_subdirectory: nil, auto_load_bundle: nil, force_load: nil) + components_subdirectory: nil, auto_load_bundle: nil, force_load: nil, + auto_load_server_components: nil, rsc_rendering_url: 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 @@ -110,6 +114,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint self.defer_generated_component_packs = defer_generated_component_packs self.force_load = force_load + self.auto_load_server_components = auto_load_server_components + self.rsc_rendering_url = rsc_rendering_url end # rubocop:enable Metrics/AbcSize diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 354d7090c..ec050bb6c 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -17,6 +17,8 @@ module Helper include ReactOnRails::Utils::Required COMPONENT_HTML_KEY = "componentHtml" + ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PC" + ADD_STORE_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PS" # react_component_name: can be a React function or class component or a "Render-Function". # "Render-Functions" differ from a React function in that they take two parameters, the @@ -362,13 +364,13 @@ def load_pack_for_generated_component(react_component_name, render_options) ReactOnRails::PackerUtils.raise_nested_entries_disabled unless ReactOnRails::PackerUtils.nested_entries? append_javascript_pack_tag("client-bundle") - # if Rails.env.development? - # is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name)) - # raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present - # end - # append_javascript_pack_tag("generated/#{react_component_name}", - # defer: ReactOnRails.configuration.defer_generated_component_packs) - # append_stylesheet_pack_tag("generated/#{react_component_name}") + if Rails.env.development? + is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name)) + raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present + end + append_javascript_pack_tag("generated/#{react_component_name}", + defer: ReactOnRails.configuration.defer_generated_component_packs) + append_stylesheet_pack_tag("generated/#{react_component_name}") end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity @@ -401,6 +403,17 @@ def run_stream_inside_fiber rendering_fiber.resume end + def registered_stores + (@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.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) @@ -512,12 +525,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, dom_id = nil) - hydrate_script = dom_id.present? ? content_tag(:script, %( -window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}'); -if (window.ReactOnRails) { - window.ReactOnRails.renderOrHydrateLoadedComponents(); -} - ).html_safe) : "" + add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');" + hydrate_script = dom_id.present? ? content_tag(:script, add_component_to_pending_hydration_code.html_safe) : "" # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. html_content = <<~HTML #{rendered_output} @@ -539,11 +548,26 @@ def rails_context_if_not_already_rendered json_safe_and_pretty(data).html_safe, type: "application/json", id: "js-react-on-rails-context") + + pending_hydration_script = <<~JS.strip_heredoc + window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; + window.REACT_ON_RAILS_PENDING_STORE_NAMES = []; + window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION} = function(domId) { + window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push(domId); + if (window.ReactOnRails) { + window.ReactOnRails.renderOrHydrateLoadedComponents(); + } + }; + window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION} = function(storeName) { + window.REACT_ON_RAILS_PENDING_STORE_NAMES.push(storeName); + if (window.ReactOnRails) { + window.ReactOnRails.hydratePendingStores(); + } + }; + JS rails_context_tag.concat( - content_tag(:script, %( -window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; - ).html_safe) - ) + content_tag(:script, pending_hydration_script.html_safe) + ).html_safe end # prepend the rails_context if not yet applied @@ -559,8 +583,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. @@ -571,7 +594,9 @@ def internal_react_component(react_component_name, options = {}) 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, + ) if render_options.force_load component_specification_tag.concat( @@ -593,12 +618,17 @@ def internal_react_component(react_component_name, options = {}) end def render_redux_store_data(redux_store_data) - result = content_tag(:script, + 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) + hydration_code = "window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION}('#{redux_store_data[:store_name]}');" + store_hydration_script = content_tag(:script, hydration_code.html_safe) - prepend_render_rails_context(result) + prepend_render_rails_context <<~HTML + #{store_hydration_data} + #{store_hydration_script} + HTML end def props_string(props) @@ -655,7 +685,7 @@ def server_rendered_react_component(render_options) 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 ) @@ -689,17 +719,18 @@ def server_rendered_react_component(render_options) 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.select { |store| store_dependencies.include?(store[:store_name]) } - 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/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index 2db3d6b0c..7faccb830 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -44,13 +44,60 @@ def create_pack(file_path) puts(Rainbow("Generated Packs: #{output_path}").yellow) end + def first_js_statement_in_code(content) + return "" if content.nil? || content.empty? + + start_index = 0 + content_length = content.length + + while start_index < content_length + # Skip whitespace + while start_index < content_length && content[start_index].match?(/\s/) + start_index += 1 + end + + 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 is_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) + register_as_server_component = ReactOnRails.configuration.auto_load_server_components && !is_client_entrypoint?(file_path) + import_statement = register_as_server_component ? "" : "import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';" + register_call = register_as_server_component ? "registerServerComponent(\"#{registered_component_name}\")" : "register({#{registered_component_name}})"; + <<~FILE_CONTENT import ReactOnRails from 'react-on-rails'; - import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}'; + #{import_statement} - ReactOnRails.register({#{registered_component_name}}); + ReactOnRails.#{register_call}; FILE_CONTENT end 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/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index eef11e502..e19036232 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,4 +1,5 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index'; +import React from 'react'; +import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction, ReactComponent } from './types/index'; import isRenderFunction from './isRenderFunction'; const registeredComponents = new Map(); @@ -60,6 +61,17 @@ export default { }); }, + registerServerComponent(...componentNames: string[]): void { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const RSCClientRoot = require('./RSCClientRoot').default; + + const componentsWrappedInRSCClientRoot = componentNames.reduce( + (acc, name) => ({ ...acc, [name]: () => React.createElement(RSCClientRoot, { componentName: name }) }), + {} + ); + this.register(componentsWrappedInRSCClientRoot); + }, + /** * @param name * @returns { name, component, isRenderFunction, isRenderer } diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts new file mode 100644 index 000000000..fa0a41e5f --- /dev/null +++ b/node_package/src/RSCClientRoot.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import RSDWClient from 'react-server-dom-webpack/client'; + +if (!('use' in React)) { + throw new Error('React.use is not defined. Please ensure you are using React 18.3.0-canary-670811593-20240322 or later to use server components.'); +} + +// It's not the exact type, but it's close enough for now +type Use = (promise: Promise) => T; +const { use } = React as { use: Use }; + +const renderCache: Record> = {}; + +const fetchRSC = ({ componentName }: { componentName: string }) => { + if (!renderCache[componentName]) { + renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)); + } + return renderCache[componentName]; +} + +const RSCClientRoot = ({ componentName }: { componentName: string }) => use(fetchRSC({ componentName })); + +export default RSCClientRoot; diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 1f5a7aedd..0626c5c68 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -55,6 +55,16 @@ ctx.ReactOnRails = { ComponentRegistry.register(components); }, + /** + * Register a specific component as a server component. + * The component will not be included in the client bundle. + * When it's rendered, a call will be made to the server to render it. + * @param componentNames + */ + registerServerComponent(...componentNames: string[]): void { + ComponentRegistry.registerServerComponent(...componentNames); + }, + registerStore(stores: { [id: string]: StoreGenerator }): void { this.registerStoreGenerators(stores); }, @@ -87,6 +97,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 @@ -140,6 +168,10 @@ ctx.ReactOnRails = { ClientStartup.renderOrHydrateLoadedComponents(); }, + hydratePendingStores(): void { + ClientStartup.hydratePendingStores(); + }, + reactOnRailsComponentLoaded(domId: string): void { ClientStartup.reactOnRailsComponentLoaded(domId); }, diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 7a44ed798..79c5da468 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -1,4 +1,3 @@ -import type { ReactElement } from 'react'; // @ts-expect-error will define this module types later import { renderToReadableStream } from 'react-server-dom-webpack/server.edge'; import { PassThrough } from 'stream'; @@ -29,7 +28,7 @@ const stringToStream = (str: string) => { const getBundleConfig = () => { const bundleConfig = JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8')); // remove file:// from keys - const newBundleConfig: { [key: string]: any } = {}; + const newBundleConfig: { [key: string]: unknown } = {}; for (const [key, value] of Object.entries(bundleConfig)) { newBundleConfig[key.replace('file://', '')] = value; } diff --git a/node_package/src/StoreRegistry.ts b/node_package/src/StoreRegistry.ts index 7be95e6f3..efc895862 100644 --- a/node_package/src/StoreRegistry.ts +++ b/node_package/src/StoreRegistry.ts @@ -2,6 +2,8 @@ import type { Store, StoreGenerator } from './types'; const registeredStoreGenerators = new Map(); const hydratedStores = new Map(); +const hydrationCallbacks = new Map void>>(); +const generatorCallbacks = new Map void>>(); export default { /** @@ -21,6 +23,12 @@ export default { } registeredStoreGenerators.set(name, store); + + const callbacks = generatorCallbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(store), 0); + }); + generatorCallbacks.delete(name); }); }, @@ -80,6 +88,12 @@ This can happen if you are server rendering and either: */ setStore(name: string, store: Store): void { hydratedStores.set(name, store); + + const callbacks = hydrationCallbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(store), 0); + }); + hydrationCallbacks.delete(name); }, /** @@ -104,4 +118,70 @@ This can happen if you are server rendering and either: stores(): Map { return hydratedStores; }, + + /** + * 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: (store: Store) => void + ): void { + // If store is already hydrated, schedule callback + const existingStore = hydratedStores.get(storeName); + if (existingStore) { + setTimeout(() => callback(existingStore), 0); + return; + } + + // Store callback for future hydration + const callbacks = hydrationCallbacks.get(storeName) || []; + callbacks.push(callback); + hydrationCallbacks.set(storeName, callbacks); + }, + + /** + * 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 + */ + async getOrWaitForStore(name: string): Promise { + return new Promise((resolve) => { + this.onStoreHydrated(name, resolve); + }); + }, + + /** + * 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: (generator: StoreGenerator) => void + ): void { + // If generator is already registered, schedule callback + const existingGenerator = registeredStoreGenerators.get(storeName); + if (existingGenerator) { + setTimeout(() => callback(existingGenerator), 0); + return; + } + + // Store callback for future registration + const callbacks = generatorCallbacks.get(storeName) || []; + callbacks.push(callback); + generatorCallbacks.set(storeName, callbacks); + }, + + /** + * 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 + */ + async getOrWaitForStoreGenerator(name: string): Promise { + return new Promise((resolve) => { + this.onStoreGeneratorRegistered(name, resolve); + }); + }, }; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 34f857144..9fbfc4a66 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -21,6 +21,7 @@ declare global { __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; roots: Root[]; REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; + REACT_ON_RAILS_PENDING_STORE_NAMES?: string[]; REACT_ON_RAILS_UNMOUNTED_BEFORE?: boolean; } @@ -29,6 +30,7 @@ declare global { ReactOnRails: ReactOnRailsType; roots: Root[]; REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; + REACT_ON_RAILS_PENDING_STORE_NAMES?: string[]; } } namespace Turbolinks { @@ -83,21 +85,14 @@ function reactOnRailsHtmlElements(): HTMLCollectionOf { return document.getElementsByClassName('js-react-on-rails-component'); } -function initializeStore(el: Element, context: Context, railsContext: RailsContext): void { +async function initializeStore(el: Element, context: Context, railsContext: RailsContext): Promise { 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 storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(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'); } @@ -209,21 +204,20 @@ function getContextAndRailsContext(): { context: Context; railsContext: RailsCon return { context, railsContext }; } +// TODO: remove it export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); - - const { context, railsContext } = getContextAndRailsContext(); - - // If no react on rails components - if (!railsContext) return; - - forEachStore(context, railsContext); } async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) { const el = document.querySelector(`[data-dom-id=${domId}]`); if (!el) return; + const storeDependencies = el.getAttribute('data-store-dependencies'); + const storeDependenciesArray = storeDependencies ? JSON.parse(storeDependencies) as string[] : []; + if (storeDependenciesArray.length > 0) { + await Promise.all(storeDependenciesArray.map(storeName => context.ReactOnRails.getOrWaitForStore(storeName))); + } await render(el, context, railsContext); } @@ -232,7 +226,6 @@ export async function renderOrHydrateLoadedComponents(): Promise { const { context, railsContext } = getContextAndRailsContext(); - // If no react on rails components if (!railsContext) return; // copy and clear the pending dom ids, so they don't get processed again @@ -245,6 +238,22 @@ export async function renderOrHydrateLoadedComponents(): Promise { ); } +export async function hydratePendingStores(): Promise { + debugTurbolinks('hydratePendingStores'); + + const { context, railsContext } = getContextAndRailsContext(); + + if (!railsContext) return; + + const pendingStoreNames = context.REACT_ON_RAILS_PENDING_STORE_NAMES ?? []; + context.REACT_ON_RAILS_PENDING_STORE_NAMES = []; + await Promise.all(pendingStoreNames.map(async (storeName) => { + const storeElement = document.querySelector(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}=${storeName}]`); + if (!storeElement) throw new Error(`Store element with name ${storeName} not found`); + await initializeStore(storeElement, context, railsContext); + })); +} + export async function reactOnRailsComponentLoaded(domId: string): Promise { debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); diff --git a/node_package/src/react-server-dom-webpack.d.ts b/node_package/src/react-server-dom-webpack.d.ts new file mode 100644 index 000000000..26e2948d1 --- /dev/null +++ b/node_package/src/react-server-dom-webpack.d.ts @@ -0,0 +1,4 @@ +declare module 'react-server-dom-webpack/client' { + // eslint-disable-next-line import/prefer-default-export + export const createFromFetch: (promise: Promise) => Promise; +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 50a4d41c4..25001222d 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -151,14 +151,18 @@ export type RenderReturnType = void | Element | Component | Root; export interface ReactOnRails { register(components: { [id: string]: ReactComponentOrRenderFunction }): void; + registerServerComponent(...componentNames: string[]): void; /** @deprecated Use registerStoreGenerators instead */ 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; renderOrHydrateLoadedComponents(): void; + hydratePendingStores(): void; reactOnRailsComponentLoaded(domId: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; diff --git a/package.json b/package.json index e4afb8d16..fb5946da5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ ".": { "rsc-server": "./node_package/lib/ReactOnRailsRSC.js", "default": "./node_package/lib/ReactOnRails.js" - } + }, + "./RSCClientRoot": "./node_package/lib/RSCClientRoot.js" }, "directories": { "doc": "docs" @@ -58,7 +59,8 @@ "peerDependencies": { "js-yaml": ">= 3.0.0", "react": ">= 16", - "react-dom": ">= 16" + "react-dom": ">= 16", + "react-server-dom-webpack": ">= 18.3.0-canary-670811593-20240322" }, "files": [ "node_package/lib" diff --git a/spec/dummy/spec/packs_generator_spec.rb b/spec/dummy/spec/packs_generator_spec.rb index f7988b337..dff16efd1 100644 --- a/spec/dummy/spec/packs_generator_spec.rb +++ b/spec/dummy/spec/packs_generator_spec.rb @@ -291,6 +291,274 @@ 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 "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 "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 "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 "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 "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 "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 "#is_client_entrypoint?", :focus do + subject { described_class.instance.send(:is_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