diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 8dbdc723a..1a14cfb8f 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( @@ -42,7 +43,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 @@ -57,7 +60,7 @@ class Configuration :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 + :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, @@ -73,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, 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, 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 @@ -113,6 +116,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 7246327ff..23d32aa83 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) @@ -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 ) @@ -675,17 +709,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/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/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..50624cc4c 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,15 +1,34 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index'; +import React from 'react'; +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 +40,7 @@ export default { const renderFunction = isRenderFunction(component); const isRenderer = renderFunction && (component as RenderFunction).length === 3; - registeredComponents.set(name, { + componentRegistry.set(name, { name, component, renderFunction, @@ -30,27 +49,40 @@ export default { }); }, + registerServerComponent(...componentNames: string[]): void { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const RSCClientRoot = (require('./RSCClientRoot') as typeof import('./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 } */ 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..bdf11d77d --- /dev/null +++ b/node_package/src/RSCClientRoot.ts @@ -0,0 +1,21 @@ +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 with experimental features enabled or React 19+ to use server components.'); +} + +const { use } = React; + +const renderCache: Record> = {}; + +const fetchRSC = ({ componentName }: { componentName: string }) => { + if (!renderCache[componentName]) { + renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)) as Promise; + } + 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 17723ad0d..d02f80da8 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import type { Readable, PassThrough } from 'stream'; import * as ClientStartup from './clientStartup'; +import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer'; import handleError from './handleError'; import ComponentRegistry from './ComponentRegistry'; import StoreRegistry from './StoreRegistry'; @@ -55,6 +56,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 +98,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 @@ -137,7 +166,11 @@ ctx.ReactOnRails = { }, reactOnRailsComponentLoaded(domId: string): void { - ClientStartup.reactOnRailsComponentLoaded(domId); + renderOrHydrateComponent(domId); + }, + + reactOnRailsStoreLoaded(storeName: string): void { + hydrateStore(storeName); }, /** @@ -240,6 +273,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/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 1be0c56e8..1f5d92562 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -1,33 +1,15 @@ -import ReactDOM from 'react-dom'; -import type { ReactElement } from 'react'; -import type { - RailsContext, - ReactOnRails as ReactOnRailsType, - RegisteredComponent, - RenderFunction, - Root, -} from './types'; - -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 NodeJS { - interface Global { - ReactOnRails: ReactOnRailsType; - roots: Root[]; - } - } namespace Turbolinks { interface TurbolinksStatic { controller?: unknown; @@ -35,30 +17,13 @@ declare global { } } -declare const ReactOnRails: ReactOnRailsType; - -const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; - -type Context = Window | NodeJS.Global; - -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 81b0569f5..e9c462ac0 100644 --- a/node_package/src/context.ts +++ b/node_package/src/context.ts @@ -1,9 +1,40 @@ +import type { ReactOnRails as ReactOnRailsType } from './types'; + +declare global { + interface Window { + ReactOnRails: ReactOnRailsType; + __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; + } + + namespace NodeJS { + interface Global { + ReactOnRails: ReactOnRailsType; + } + } + namespace Turbolinks { + interface TurbolinksStatic { + controller?: unknown; + } + } +} + +export type Context = Window | NodeJS.Global; + /** * Get the context, be it window or global * @returns {boolean|Window|*|context} */ -export default function context(this: void): Window | NodeJS.Global | void { +export default function context(this: void): Context | void { return ((typeof window !== 'undefined') && window) || ((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/types/index.ts b/node_package/src/types/index.ts index d48924bcd..beb8b17f5 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,3 +1,6 @@ +// eslint-disable-next-line spaced-comment +/// + import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; import type { Readable, PassThrough } from 'stream'; @@ -104,6 +107,8 @@ export interface RegisteredComponent { isRenderer: boolean; } +export type ItemRegistrationCallback = (component: T) => void; + interface Params { props?: Record; railsContext?: RailsContext; @@ -151,14 +156,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; 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,6 +178,7 @@ 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; diff --git a/node_package/types/react-server-dom-webpack.d.ts b/node_package/types/react-server-dom-webpack.d.ts new file mode 100644 index 000000000..31c75a634 --- /dev/null +++ b/node_package/types/react-server-dom-webpack.d.ts @@ -0,0 +1,5 @@ +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 62f94dd67..64767d1a4 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 diff --git a/tsconfig.json b/tsconfig.json index 13fa3eb7a..d66366c8d 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/**/*"] + "include": ["node_package/src/**/*", "node_package/types/**/*"] }