diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 10c0c9818d..34837cc953 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -650,8 +650,7 @@ def internal_react_component(react_component_name, options = {}) "data-trace" => (render_options.trace ? true : nil), "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), - "data-render-request-id" => render_options.render_request_id) + "data-force-load" => (render_options.force_load ? true : nil)) if render_options.force_load component_specification_tag.concat( diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index e0a0303ae8..654aeece58 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -15,17 +15,9 @@ class RenderOptions def initialize(react_component_name: required("react_component_name"), options: required("options")) @react_component_name = react_component_name.camelize @options = options - # The render_request_id serves as a unique identifier for each render request. - # We cannot rely solely on dom_id, as it should be unique for each component on the page, - # but the server can render the same page multiple times concurrently for different users. - # Therefore, we need an additional unique identifier that can be used both on the client and server. - # This ID can also be used to associate specific data with a particular rendered component - # on either the server or client. - # This ID is only present if RSC support is enabled because it's only used in that case. - @render_request_id = self.class.generate_request_id if ReactOnRails::Utils.rsc_support_enabled? end - attr_reader :react_component_name, :render_request_id + attr_reader :react_component_name def throw_js_errors options.fetch(:throw_js_errors, false) diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index 51ca5460dd..b4978c5985 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -83,18 +83,6 @@ class ComponentRenderer { const { domNodeId } = this; const props = el.textContent !== null ? (JSON.parse(el.textContent) as Record) : {}; const trace = el.getAttribute('data-trace') === 'true'; - const renderRequestId = el.getAttribute('data-render-request-id'); - - // The renderRequestId is optional and only present when React Server Components (RSC) support is enabled. - // When RSC is enabled, this ID helps track and associate server-rendered components with their client-side hydration. - const componentSpecificRailsContext = renderRequestId - ? { - ...railsContext, - componentSpecificMetadata: { - renderRequestId, - }, - } - : railsContext; try { const domNode = document.getElementById(domNodeId); @@ -105,7 +93,7 @@ class ComponentRenderer { } if ( - (await delegateToRenderer(componentObj, props, componentSpecificRailsContext, domNodeId, trace)) || + (await delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) || // @ts-expect-error The state can change while awaiting delegateToRenderer this.state === 'unmounted' ) { @@ -120,7 +108,7 @@ class ComponentRenderer { props, domNodeId, trace, - railsContext: componentSpecificRailsContext, + railsContext, shouldHydrate, }); diff --git a/node_package/src/PostSSRHookTracker.ts b/node_package/src/PostSSRHookTracker.ts new file mode 100644 index 0000000000..f18de7f1dd --- /dev/null +++ b/node_package/src/PostSSRHookTracker.ts @@ -0,0 +1,65 @@ +type PostSSRHook = () => void; + +/** + * Post-SSR Hook Tracker - manages post-SSR hooks for a single request. + * + * This class provides a local alternative to the global hook management, + * allowing each request to have its own isolated hook tracker without sharing state. + * + * The tracker ensures that: + * - Hooks are executed exactly once when SSR ends + * - No hooks can be added after SSR has completed + * - Proper cleanup occurs to prevent memory leaks + */ +class PostSSRHookTracker { + private hooks: PostSSRHook[] = []; + + private hasSSREnded = false; + + /** + * Adds a hook to be executed when SSR ends for this request. + * + * @param hook - Function to call when SSR ends + * @throws Error if called after SSR has already ended + */ + addPostSSRHook(hook: PostSSRHook): void { + if (this.hasSSREnded) { + console.error( + 'Cannot add post-SSR hook: SSR has already ended for this request. ' + + 'Hooks must be registered before or during the SSR process.', + ); + return; + } + + this.hooks.push(hook); + } + + /** + * Notifies all registered hooks that SSR has ended and clears the hook list. + * This should be called exactly once when server-side rendering is complete. + * + * @throws Error if called multiple times + */ + notifySSREnd(): void { + if (this.hasSSREnded) { + console.warn('notifySSREnd() called multiple times. This may indicate a bug in the SSR lifecycle.'); + return; + } + + this.hasSSREnded = true; + + // Execute all hooks and handle any errors gracefully + this.hooks.forEach((hook, index) => { + try { + hook(); + } catch (error) { + console.error(`Error executing post-SSR hook ${index}:`, error); + } + }); + + // Clear hooks to free memory + this.hooks = []; + } +} + +export default PostSSRHookTracker; diff --git a/node_package/src/RSCPayloadGenerator.ts b/node_package/src/RSCPayloadGenerator.ts deleted file mode 100644 index 2804ea969c..0000000000 --- a/node_package/src/RSCPayloadGenerator.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { PassThrough, Readable } from 'stream'; -import { - RailsContextWithServerComponentCapabilities, - RSCPayloadStreamInfo, - RSCPayloadCallback, -} from './types/index.ts'; - -declare global { - function generateRSCPayload( - componentName: string, - props: unknown, - railsContext: RailsContextWithServerComponentCapabilities, - ): Promise; -} - -const rscPayloadStreams = new Map(); -const rscPayloadCallbacks = new Map(); - -/** - * TTL (Time To Live) tracking for RSC payload cleanup. - * This Map stores timeout IDs for automatic cleanup of RSC payload data. - * The TTL mechanism serves as a safety net to prevent memory leaks in case - * the normal cleanup path (via clearRSCPayloadStreams) is not called. - */ -const rscPayloadTTLs = new Map(); - -/** - * Default TTL duration of 5 minutes (300000 ms). - * This duration should be long enough to accommodate normal request processing - * while preventing long-term memory leaks if cleanup is missed. - */ -const DEFAULT_TTL = 300000; - -export const clearRSCPayloadStreams = (railsContext: RailsContextWithServerComponentCapabilities) => { - const { renderRequestId } = railsContext.componentSpecificMetadata; - // Close any active streams before clearing - const streams = rscPayloadStreams.get(renderRequestId); - if (streams) { - streams.forEach(({ stream }) => { - if (typeof (stream as Readable).destroy === 'function') { - (stream as Readable).destroy(); - } - }); - } - rscPayloadStreams.delete(renderRequestId); - rscPayloadCallbacks.delete(renderRequestId); - - // Clear TTL if it exists - const ttl = rscPayloadTTLs.get(renderRequestId); - if (ttl) { - clearTimeout(ttl); - rscPayloadTTLs.delete(renderRequestId); - } -}; - -/** - * Schedules automatic cleanup of RSC payload data after a TTL period. - * The TTL mechanism is necessary because: - * - It prevents memory leaks if clearRSCPayloadStreams is not called (e.g., due to errors) - * - It ensures cleanup happens even if the request is abandoned or times out - * - It provides a safety net for edge cases where the normal cleanup path might be missed - * - * @param railsContext - The Rails context containing the renderRequestId to schedule cleanup for - */ -function scheduleCleanup(railsContext: RailsContextWithServerComponentCapabilities) { - const { renderRequestId } = railsContext.componentSpecificMetadata; - // Clear any existing TTL to prevent multiple cleanup timers - const existingTTL = rscPayloadTTLs.get(renderRequestId); - if (existingTTL) { - clearTimeout(existingTTL); - } - - // Set new TTL that will trigger cleanup after DEFAULT_TTL milliseconds - const ttl = setTimeout(() => { - clearRSCPayloadStreams(railsContext); - }, DEFAULT_TTL); - - rscPayloadTTLs.set(renderRequestId, ttl); -} - -/** - * Registers a callback to be executed when RSC payloads are generated. - * - * This function: - * 1. Stores the callback function by railsContext - * 2. Immediately executes the callback for any existing streams - * - * This synchronous execution is critical for preventing hydration race conditions. - * It ensures payload array initialization happens before component HTML appears - * in the response stream. - * - * @param railsContext - Context for the current request - * @param callback - Function to call when an RSC payload is generated - */ -export const onRSCPayloadGenerated = ( - railsContext: RailsContextWithServerComponentCapabilities, - callback: RSCPayloadCallback, -) => { - const { renderRequestId } = railsContext.componentSpecificMetadata; - const callbacks = rscPayloadCallbacks.get(renderRequestId); - if (callbacks) { - callbacks.push(callback); - } else { - rscPayloadCallbacks.set(renderRequestId, [callback]); - } - - // This ensures we have a safety net even if the normal cleanup path fails - scheduleCleanup(railsContext); - - // Call callback for any existing streams for this context - const existingStreams = rscPayloadStreams.get(renderRequestId); - if (existingStreams) { - existingStreams.forEach((streamInfo) => callback(streamInfo)); - } -}; - -/** - * Generates and tracks RSC payloads for server components. - * - * getRSCPayloadStream: - * 1. Calls the global generateRSCPayload function - * 2. Tracks streams by railsContext for later injection - * 3. Notifies callbacks immediately to enable early payload embedding - * - * The immediate callback notification is critical for preventing hydration race conditions, - * as it ensures the payload array is initialized in the HTML stream before component rendering. - * - * @param componentName - Name of the server component - * @param props - Props for the server component - * @param railsContext - Context for the current request - * @returns A stream of the RSC payload - */ -export const getRSCPayloadStream = async ( - componentName: string, - props: unknown, - railsContext: RailsContextWithServerComponentCapabilities, -): Promise => { - if (typeof generateRSCPayload !== 'function') { - throw new Error( - 'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' + - 'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' + - 'is set to true.', - ); - } - - const { renderRequestId } = railsContext.componentSpecificMetadata; - const stream = await generateRSCPayload(componentName, props, railsContext); - // Tee stream to allow for multiple consumers: - // 1. stream1 - Used by React's runtime to perform server-side rendering - // 2. stream2 - Used by react-on-rails to embed the RSC payloads - // into the HTML stream for client-side hydration - const stream1 = new PassThrough(); - stream.pipe(stream1); - const stream2 = new PassThrough(); - stream.pipe(stream2); - - const streamInfo: RSCPayloadStreamInfo = { - componentName, - props, - stream: stream2, - }; - const streams = rscPayloadStreams.get(renderRequestId); - if (streams) { - streams.push(streamInfo); - } else { - rscPayloadStreams.set(renderRequestId, [streamInfo]); - } - - // Notify callbacks about the new stream in a sync manner to maintain proper hydration timing - // as described in the comment above onRSCPayloadGenerated - const callbacks = rscPayloadCallbacks.get(renderRequestId); - if (callbacks) { - callbacks.forEach((callback) => callback(streamInfo)); - } - - return stream1; -}; - -export const getRSCPayloadStreams = ( - railsContext: RailsContextWithServerComponentCapabilities, -): { - componentName: string; - props: unknown; - stream: NodeJS.ReadableStream; -}[] => { - const { renderRequestId } = railsContext.componentSpecificMetadata; - return rscPayloadStreams.get(renderRequestId) ?? []; -}; diff --git a/node_package/src/RSCProvider.tsx b/node_package/src/RSCProvider.tsx index 86979d7ba6..61da252972 100644 --- a/node_package/src/RSCProvider.tsx +++ b/node_package/src/RSCProvider.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { RailsContextWithComponentSpecificMetadata } from './types/index.ts'; -import getReactServerComponent from './getReactServerComponent.client.ts'; +import type { ClientGetReactServerComponentProps } from './getReactServerComponent.client.ts'; import { createRSCPayloadKey } from './utils.ts'; type RSCContextType = { @@ -31,31 +30,28 @@ const RSCContext = React.createContext(undefined); * for client-side rendering or 'react-on-rails/wrapServerComponentRenderer/server' for server-side rendering. */ export const createRSCProvider = ({ - railsContext, getServerComponent, }: { - railsContext: RailsContextWithComponentSpecificMetadata; - getServerComponent: typeof getReactServerComponent; + getServerComponent: (props: ClientGetReactServerComponentProps) => Promise; }) => { const fetchRSCPromises: Record> = {}; const getComponent = (componentName: string, componentProps: unknown) => { - const key = createRSCPayloadKey(componentName, componentProps, railsContext); + const key = createRSCPayloadKey(componentName, componentProps); if (key in fetchRSCPromises) { return fetchRSCPromises[key]; } - const promise = getServerComponent({ componentName, componentProps, railsContext }); + const promise = getServerComponent({ componentName, componentProps }); fetchRSCPromises[key] = promise; return promise; }; const refetchComponent = (componentName: string, componentProps: unknown) => { - const key = createRSCPayloadKey(componentName, componentProps, railsContext); + const key = createRSCPayloadKey(componentName, componentProps); const promise = getServerComponent({ componentName, componentProps, - railsContext, enforceRefetch: true, }); fetchRSCPromises[key] = promise; diff --git a/node_package/src/RSCRequestTracker.ts b/node_package/src/RSCRequestTracker.ts new file mode 100644 index 0000000000..9c6e3e7c5c --- /dev/null +++ b/node_package/src/RSCRequestTracker.ts @@ -0,0 +1,161 @@ +import { PassThrough, Readable } from 'stream'; +import { extractErrorMessage } from './utils.ts'; +import { + RSCPayloadStreamInfo, + RSCPayloadCallback, + RailsContextWithServerComponentMetadata, +} from './types/index.ts'; + +/** + * Global function provided by React on Rails Pro for generating RSC payloads. + * + * This function is injected into the global scope during server-side rendering + * by the RORP rendering request. It handles the actual generation of React Server + * Component payloads on the server side. + * + * @see https://github.com/shakacode/react_on_rails_pro/blob/master/lib/react_on_rails_pro/server_rendering_js_code.rb + */ +declare global { + function generateRSCPayload( + componentName: string, + props: unknown, + railsContext: RailsContextWithServerComponentMetadata, + ): Promise; +} + +/** + * RSC Request Tracker - manages RSC payload generation and tracking for a single request. + * + * This class provides a local alternative to the global RSC payload management, + * allowing each request to have its own isolated tracker without sharing state. + * It includes both tracking functionality for the server renderer and fetching + * functionality for components. + */ +class RSCRequestTracker { + private streams: RSCPayloadStreamInfo[] = []; + + private callbacks: RSCPayloadCallback[] = []; + + private railsContext: RailsContextWithServerComponentMetadata; + + constructor(railsContext: RailsContextWithServerComponentMetadata) { + this.railsContext = railsContext; + } + + /** + * Clears all streams and callbacks for this request. + * Should be called when the request is complete to ensure proper cleanup, + * though garbage collection will handle cleanup automatically when the tracker goes out of scope. + * + * This method is safe to call multiple times and will handle any errors during cleanup gracefully. + */ + clear(): void { + // Close any active streams before clearing + this.streams.forEach(({ stream, componentName }, index) => { + try { + if (stream && typeof (stream as Readable).destroy === 'function') { + (stream as Readable).destroy(); + } + } catch (error) { + // Log the error but don't throw to avoid disrupting cleanup of other streams + console.warn( + `Warning: Error while destroying RSC stream for component "${componentName}" at index ${index}:`, + error, + ); + } + }); + + this.streams = []; + this.callbacks = []; + } + + /** + * Registers a callback to be executed when RSC payloads are generated. + * + * This function: + * 1. Stores the callback function for this tracker + * 2. Immediately executes the callback for any existing streams + * + * This synchronous execution is critical for preventing hydration race conditions. + * It ensures payload array initialization happens before component HTML appears + * in the response stream. + * + * @param callback - Function to call when an RSC payload is generated + */ + onRSCPayloadGenerated(callback: RSCPayloadCallback): void { + this.callbacks.push(callback); + + // Call callback for any existing streams + this.streams.forEach(callback); + } + + /** + * Generates and tracks RSC payloads for server components. + * + * getRSCPayloadStream: + * 1. Calls the provided generateRSCPayload function + * 2. Tracks streams in this tracker for later access + * 3. Notifies callbacks immediately to enable early payload embedding + * + * The immediate callback notification is critical for preventing hydration race conditions, + * as it ensures the payload array is initialized in the HTML stream before component rendering. + * + * @param componentName - Name of the server component + * @param props - Props for the server component + * @returns A stream of the RSC payload + * @throws Error if generateRSCPayload is not available or fails + */ + async getRSCPayloadStream(componentName: string, props: unknown): Promise { + // Validate that the global generateRSCPayload function is available + if (typeof generateRSCPayload !== 'function') { + throw new Error( + 'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' + + 'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' + + 'is set to true.', + ); + } + + try { + const stream = await generateRSCPayload(componentName, props, this.railsContext); + + // Tee stream to allow for multiple consumers: + // 1. stream1 - Used by React's runtime to perform server-side rendering + // 2. stream2 - Used by react-on-rails to embed the RSC payloads + // into the HTML stream for client-side hydration + const stream1 = new PassThrough(); + stream.pipe(stream1); + const stream2 = new PassThrough(); + stream.pipe(stream2); + + const streamInfo: RSCPayloadStreamInfo = { + componentName, + props, + stream: stream2, + }; + + this.streams.push(streamInfo); + + // Notify callbacks about the new stream in a sync manner to maintain proper hydration timing + this.callbacks.forEach((callback) => callback(streamInfo)); + + return stream1; + } catch (error) { + // Provide a more helpful error message that includes context + throw new Error( + `Failed to generate RSC payload for component "${componentName}": ${extractErrorMessage(error)}`, + ); + } + } + + /** + * Returns all RSC payload streams tracked by this request tracker. + * Used by the server renderer to access all fetched RSCs for this request. + * + * @returns Array of RSC payload stream information + */ + getRSCPayloadStreams(): RSCPayloadStreamInfo[] { + return [...this.streams]; // Return a copy to prevent external mutation + } +} + +export default RSCRequestTracker; diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index d07a911f61..752fba1d4c 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -190,12 +190,6 @@ globalThis.ReactOnRails = { }, isRSCBundle: false, - - addPostSSRHook(): void { - throw new Error( - 'addPostSSRHook is not available in "react-on-rails/client". Import "react-on-rails" server-side.', - ); - }, }; globalThis.ReactOnRails.resetOptions(); diff --git a/node_package/src/ReactOnRails.node.ts b/node_package/src/ReactOnRails.node.ts index 62562d2db0..407d2658b9 100644 --- a/node_package/src/ReactOnRails.node.ts +++ b/node_package/src/ReactOnRails.node.ts @@ -1,19 +1,7 @@ import ReactOnRails from './ReactOnRails.full.ts'; import streamServerRenderedReactComponent from './streamServerRenderedReactComponent.ts'; -import { - getRSCPayloadStream, - getRSCPayloadStreams, - clearRSCPayloadStreams, - onRSCPayloadGenerated, -} from './RSCPayloadGenerator.ts'; -import { addPostSSRHook } from './postSSRHooks.ts'; ReactOnRails.streamServerRenderedReactComponent = streamServerRenderedReactComponent; -ReactOnRails.getRSCPayloadStream = getRSCPayloadStream; -ReactOnRails.getRSCPayloadStreams = getRSCPayloadStreams; -ReactOnRails.clearRSCPayloadStreams = clearRSCPayloadStreams; -ReactOnRails.onRSCPayloadGenerated = onRSCPayloadGenerated; -ReactOnRails.addPostSSRHook = addPostSSRHook; export * from './ReactOnRails.full.ts'; // eslint-disable-next-line no-restricted-exports -- see https://github.com/eslint/eslint/issues/15617 diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index ea34574a33..4089238ca0 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -4,17 +4,17 @@ import { Readable } from 'stream'; import { RSCRenderParams, - assertRailsContextWithServerComponentCapabilities, + assertRailsContextWithServerStreamingCapabilities, StreamRenderState, StreamableComponentResult, } from './types/index.ts'; import ReactOnRails from './ReactOnRails.full.ts'; import handleError from './handleError.ts'; import { convertToError } from './serverRenderUtils.ts'; -import { notifySSREnd, addPostSSRHook } from './postSSRHooks.ts'; import { streamServerRenderedComponent, + StreamingTrackers, transformRenderStreamChunksToResultObject, } from './streamServerRenderedReactComponent.ts'; import loadJsonFile from './loadJsonFile.ts'; @@ -24,10 +24,11 @@ let serverRendererPromise: Promise> | und const streamRenderRSCComponent = ( reactRenderingResult: StreamableComponentResult, options: RSCRenderParams, + streamingTrackers: StreamingTrackers, ): Readable => { const { throwJsErrors } = options; const { railsContext } = options; - assertRailsContextWithServerComponentCapabilities(railsContext); + assertRailsContextWithServerStreamingCapabilities(railsContext); const { reactClientManifestFileName } = railsContext; const renderState: StreamRenderState = { @@ -77,7 +78,7 @@ const streamRenderRSCComponent = ( }); readableStream.on('end', () => { - notifySSREnd(railsContext); + streamingTrackers.postSSRHookTracker.notifySSREnd(); }); return readableStream; }; @@ -90,8 +91,6 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; -ReactOnRails.addPostSSRHook = addPostSSRHook; - ReactOnRails.isRSCBundle = true; export * from './types/index.ts'; diff --git a/node_package/src/getReactServerComponent.client.ts b/node_package/src/getReactServerComponent.client.ts index 8a44ba5b84..45b1a5b4f8 100644 --- a/node_package/src/getReactServerComponent.client.ts +++ b/node_package/src/getReactServerComponent.client.ts @@ -1,8 +1,8 @@ import * as React from 'react'; import { createFromReadableStream } from 'react-on-rails-rsc/client.browser'; -import { createRSCPayloadKey, fetch, wrapInNewPromise } from './utils.ts'; +import { createRSCPayloadKey, fetch, wrapInNewPromise, extractErrorMessage } from './utils.ts'; import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts'; -import { assertRailsContextWithComponentSpecificMetadata, RailsContext } from './types/index.ts'; +import { RailsContext } from './types/index.ts'; declare global { interface Window { @@ -10,10 +10,9 @@ declare global { } } -type ClientGetReactServerComponentProps = { +export type ClientGetReactServerComponentProps = { componentName: string; componentProps: unknown; - railsContext: RailsContext; enforceRefetch?: boolean; }; @@ -39,15 +38,43 @@ const createFromFetch = async (fetchPromise: Promise) => { * This is used for client-side navigation or when rendering components * that weren't part of the initial server render. * - * @param props - Object containing component name, props, and railsContext + * @param componentName - Name of the server component + * @param componentProps - Props for the server component + * @param railsContext - The Rails context containing configuration * @returns A Promise resolving to the rendered React element + * @throws Error if RSC payload generation URL path is not configured or network request fails */ -const fetchRSC = ({ componentName, componentProps, railsContext }: ClientGetReactServerComponentProps) => { - const propsString = JSON.stringify(componentProps); +const fetchRSC = ({ + componentName, + componentProps, + railsContext, +}: ClientGetReactServerComponentProps & { railsContext: RailsContext }) => { const { rscPayloadGenerationUrlPath } = railsContext; - const strippedUrlPath = rscPayloadGenerationUrlPath?.replace(/^\/|\/$/g, ''); - const encodedParams = new URLSearchParams({ props: propsString }).toString(); - return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?${encodedParams}`)); + + if (!rscPayloadGenerationUrlPath) { + throw new Error( + `Cannot fetch RSC payload for component "${componentName}": rscPayloadGenerationUrlPath is not configured. ` + + 'Please ensure React Server Components support is properly enabled and configured.', + ); + } + + try { + const propsString = JSON.stringify(componentProps); + const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, ''); + const encodedParams = new URLSearchParams({ props: propsString }).toString(); + const fetchUrl = `/${strippedUrlPath}/${componentName}?${encodedParams}`; + + return createFromFetch(fetch(fetchUrl)).catch((error: unknown) => { + throw new Error( + `Failed to fetch RSC payload for component "${componentName}" from "${fetchUrl}": ${extractErrorMessage(error)}`, + ); + }); + } catch (error: unknown) { + // Handle JSON.stringify errors or other synchronous errors + throw new Error( + `Failed to prepare RSC request for component "${componentName}": ${extractErrorMessage(error)}`, + ); + } }; const createRSCStreamFromArray = (payloads: string[]) => { @@ -104,10 +131,16 @@ const createFromPreloadedPayloads = (payloads: string[]) => { }; /** - * Fetches and renders a server component on the client side. + * Creates a function that fetches and renders a server component on the client side. * - * This function: - * 1. Checks for embedded RSC payloads in window.REACT_ON_RAILS_RSC_PAYLOADS + * This style of higher-order function is necessary as the function that gets server components + * on server has different parameters than the function that gets them on client. The environment + * dependent parameters (domNodeId, railsContext) are passed from the `wrapServerComponentRenderer` + * function, while the environment agnostic parameters (componentName, componentProps, enforceRefetch) + * are passed from the RSCProvider which is environment agnostic. + * + * The returned function: + * 1. Checks for embedded RSC payloads in window.REACT_ON_RAILS_RSC_PAYLOADS using the domNodeId * 2. If found, uses the embedded payload to avoid an HTTP request * 3. If not found (during client navigation or dynamic rendering), fetches via HTTP * 4. Processes the RSC payload into React elements @@ -115,30 +148,31 @@ const createFromPreloadedPayloads = (payloads: string[]) => { * The embedded payload approach ensures optimal performance during initial page load, * while the HTTP fallback enables dynamic rendering after navigation. * + * @param domNodeId - The DOM node ID to create a unique key for the RSC payload store + * @param railsContext - Context for the current request, shared across all components + * @returns A function that accepts RSC parameters and returns a Promise resolving to the rendered React element + * + * The returned function accepts: * @param componentName - Name of the server component to render * @param componentProps - Props to pass to the server component - * @param railsContext - Context for the current request * @param enforceRefetch - Whether to enforce a refetch of the component - * @returns A Promise resolving to the rendered React element * * @important This is an internal function. End users should not use this directly. * Instead, use the useRSC hook which provides getComponent and refetchComponent functions * for fetching or retrieving cached server components. For rendering server components, * consider using RSCRoute component which handles the rendering logic automatically. */ -const getReactServerComponent = ({ - componentName, - componentProps, - railsContext, - enforceRefetch = false, -}: ClientGetReactServerComponentProps) => { - assertRailsContextWithComponentSpecificMetadata(railsContext); - const componentKey = createRSCPayloadKey(componentName, componentProps, railsContext); - const payloads = window.REACT_ON_RAILS_RSC_PAYLOADS?.[componentKey]; - if (!enforceRefetch && payloads) { - return createFromPreloadedPayloads(payloads); - } - return fetchRSC({ componentName, componentProps, railsContext }); -}; +const getReactServerComponent = + (domNodeId: string, railsContext: RailsContext) => + ({ componentName, componentProps, enforceRefetch = false }: ClientGetReactServerComponentProps) => { + if (!enforceRefetch && window.REACT_ON_RAILS_RSC_PAYLOADS) { + const rscPayloadKey = createRSCPayloadKey(componentName, componentProps, domNodeId); + const payloads = window.REACT_ON_RAILS_RSC_PAYLOADS[rscPayloadKey]; + if (payloads) { + return createFromPreloadedPayloads(payloads); + } + } + return fetchRSC({ componentName, componentProps, railsContext }); + }; export default getReactServerComponent; diff --git a/node_package/src/getReactServerComponent.server.ts b/node_package/src/getReactServerComponent.server.ts index f8068c9aeb..885a712f16 100644 --- a/node_package/src/getReactServerComponent.server.ts +++ b/node_package/src/getReactServerComponent.server.ts @@ -2,12 +2,11 @@ import { BundleManifest } from 'react-on-rails-rsc'; import { buildClientRenderer } from 'react-on-rails-rsc/client.node'; import transformRSCStream from './transformRSCNodeStream.ts'; import loadJsonFile from './loadJsonFile.ts'; -import { assertRailsContextWithServerComponentCapabilities, RailsContext } from './types/index.ts'; +import type { RailsContextWithServerStreamingCapabilities } from './types/index.ts'; type GetReactServerComponentOnServerProps = { componentName: string; componentProps: unknown; - railsContext: RailsContext; }; let clientRendererPromise: Promise> | undefined; @@ -37,9 +36,15 @@ const createFromReactOnRailsNodeStream = async ( }; /** - * Fetches and renders a server component on the server side. + * Creates a function that fetches and renders a server component on the server side. * - * This function: + * This style of higher-order function is necessary as the function that gets server components + * on server has different parameters than the function that gets them on client. The environment + * dependent parameters (railsContext) are passed from the `wrapServerComponentRenderer` + * function, while the environment agnostic parameters (componentName, componentProps) are + * passed from the RSCProvider which is environment agnostic. + * + * The returned function: * 1. Validates the railsContext for required properties * 2. Creates an SSR manifest mapping server and client modules * 3. Gets the RSC payload stream via getRSCPayloadStream @@ -49,41 +54,28 @@ const createFromReactOnRailsNodeStream = async ( * - Used to render the server component * - Tracked so it can be embedded in the HTML response * + * @param railsContext - Context for the current request with server streaming capabilities + * @returns A function that accepts RSC parameters and returns a Promise resolving to the rendered React element + * + * The returned function accepts: * @param componentName - Name of the server component to render * @param componentProps - Props to pass to the server component - * @param railsContext - Context for the current request - * @param enforceRefetch - Whether to enforce a refetch of the component - * @returns A Promise resolving to the rendered React element * * @important This is an internal function. End users should not use this directly. * Instead, use the useRSC hook which provides getComponent and refetchComponent functions * for fetching or retrieving cached server components. For rendering server components, * consider using RSCRoute component which handles the rendering logic automatically. */ -const getReactServerComponent = async ({ - componentName, - componentProps, - railsContext, -}: GetReactServerComponentOnServerProps) => { - assertRailsContextWithServerComponentCapabilities(railsContext); +const getReactServerComponent = + (railsContext: RailsContextWithServerStreamingCapabilities) => + async ({ componentName, componentProps }: GetReactServerComponentOnServerProps) => { + const rscPayloadStream = await railsContext.getRSCPayloadStream(componentName, componentProps); - if (typeof ReactOnRails.getRSCPayloadStream !== 'function') { - throw new Error( - 'ReactOnRails.getRSCPayloadStream is not defined. This likely means that you are not building the server bundle correctly. Please ensure that your server bundle is targeting Node.js', + return createFromReactOnRailsNodeStream( + rscPayloadStream, + railsContext.reactServerClientManifestFileName, + railsContext.reactClientManifestFileName, ); - } - - const rscPayloadStream = await ReactOnRails.getRSCPayloadStream( - componentName, - componentProps, - railsContext, - ); - - return createFromReactOnRailsNodeStream( - rscPayloadStream, - railsContext.reactServerClientManifestFileName, - railsContext.reactClientManifestFileName, - ); -}; + }; export default getReactServerComponent; diff --git a/node_package/src/injectRSCPayload.ts b/node_package/src/injectRSCPayload.ts index 401c83760c..0dea3ab0bc 100644 --- a/node_package/src/injectRSCPayload.ts +++ b/node_package/src/injectRSCPayload.ts @@ -1,7 +1,8 @@ import { PassThrough } from 'stream'; import { finished } from 'stream/promises'; import { createRSCPayloadKey } from './utils.ts'; -import { RailsContextWithServerComponentCapabilities, PipeableOrReadableStream } from './types/index.ts'; +import { PipeableOrReadableStream } from './types/index.ts'; +import RSCRequestTracker from './RSCRequestTracker.ts'; // In JavaScript, when an escape sequence with a backslash (\) is followed by a character // that isn't a recognized escape character, the backslash is ignored, and the character @@ -59,7 +60,8 @@ function createRSCPayloadChunk(chunk: string, cacheKey: string) { */ export default function injectRSCPayload( pipeableHtmlStream: PipeableOrReadableStream, - railsContext: RailsContextWithServerComponentCapabilities, + rscRequestTracker: RSCRequestTracker, + domNodeId: string | undefined, ) { const htmlStream = new PassThrough(); pipeableHtmlStream.pipe(htmlStream); @@ -206,17 +208,9 @@ export default function injectRSCPayload( try { const rscPromises: Promise[] = []; - if (!ReactOnRails.onRSCPayloadGenerated) { - // This should never occur in normal operation and indicates a bug in react_on_rails that needs to be fixed. - // While not fatal, missing RSC payload injection will degrade performance by forcing - console.error( - 'ReactOnRails Error: ReactOnRails.onRSCPayloadGenerated is not defined. RSC payloads cannot be injected.', - ); - } - - ReactOnRails.onRSCPayloadGenerated?.(railsContext, (streamInfo) => { + rscRequestTracker.onRSCPayloadGenerated((streamInfo) => { const { stream, props, componentName } = streamInfo; - const cacheKey = createRSCPayloadKey(componentName, props, railsContext); + const rscPayloadKey = createRSCPayloadKey(componentName, props, domNodeId); // CRITICAL TIMING: Initialize global array IMMEDIATELY when component requests RSC // This ensures the array exists before the component's HTML is rendered and sent. @@ -224,7 +218,7 @@ export default function injectRSCPayload( // // The initialization script creates: (self.REACT_ON_RAILS_RSC_PAYLOADS||={})[cacheKey]||=[] // This creates a global array that the client-side RSCProvider monitors for new chunks. - const initializationScript = createRSCPayloadInitializationScript(cacheKey); + const initializationScript = createRSCPayloadInitializationScript(rscPayloadKey); rscInitializationBuffers.push(Buffer.from(initializationScript)); // Process RSC payload stream asynchronously @@ -232,7 +226,7 @@ export default function injectRSCPayload( (async () => { for await (const chunk of stream ?? []) { const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk); - const payloadScript = createRSCPayloadChunk(decodedChunk, cacheKey); + const payloadScript = createRSCPayloadChunk(decodedChunk, rscPayloadKey); rscPayloadBuffers.push(Buffer.from(payloadScript)); scheduleFlush(); } @@ -306,12 +300,7 @@ export default function injectRSCPayload( rscPromise .then(cleanup) .finally(() => { - if (!ReactOnRails.clearRSCPayloadStreams) { - console.error('ReactOnRails Error: clearRSCPayloadStreams is not a function'); - return; - } - - ReactOnRails.clearRSCPayloadStreams(railsContext); + rscRequestTracker.clear(); }) .catch((err: unknown) => resultStream.emit('error', err)); }); diff --git a/node_package/src/postSSRHooks.ts b/node_package/src/postSSRHooks.ts deleted file mode 100644 index ce5ebee7ad..0000000000 --- a/node_package/src/postSSRHooks.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { RailsContextWithServerComponentCapabilities } from './types/index.ts'; - -type PostSSRHook = () => void; -const postSSRHooks = new Map(); - -export const addPostSSRHook = ( - railsContext: RailsContextWithServerComponentCapabilities, - hook: PostSSRHook, -) => { - const hooks = postSSRHooks.get(railsContext.componentSpecificMetadata.renderRequestId); - if (hooks) { - hooks.push(hook); - } else { - postSSRHooks.set(railsContext.componentSpecificMetadata.renderRequestId, [hook]); - } -}; - -export const notifySSREnd = (railsContext: RailsContextWithServerComponentCapabilities) => { - const hooks = postSSRHooks.get(railsContext.componentSpecificMetadata.renderRequestId); - if (hooks) { - hooks.forEach((hook) => hook()); - } - postSSRHooks.delete(railsContext.componentSpecificMetadata.renderRequestId); -}; diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 71f9fe075e..cf6436021c 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -9,14 +9,17 @@ import handleError from './handleError.ts'; import { renderToPipeableStream } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import { - assertRailsContextWithServerComponentCapabilities, + assertRailsContextWithServerStreamingCapabilities, RenderParams, StreamRenderState, StreamableComponentResult, PipeableOrReadableStream, + RailsContextWithServerStreamingCapabilities, + assertRailsContextWithServerComponentMetadata, } from './types/index.ts'; import injectRSCPayload from './injectRSCPayload.ts'; -import { notifySSREnd } from './postSSRHooks.ts'; +import PostSSRHookTracker from './PostSSRHookTracker.ts'; +import RSCRequestTracker from './RSCRequestTracker.ts'; type BufferedEvent = { event: 'data' | 'error' | 'end'; @@ -135,9 +138,15 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen return { readableStream, pipeToTransform, writeChunk, emitError, endStream }; }; +export type StreamingTrackers = { + postSSRHookTracker: PostSSRHookTracker; + rscRequestTracker: RSCRequestTracker; +}; + const streamRenderReactComponent = ( reactRenderingResult: StreamableComponentResult, options: RenderParams, + streamingTrackers: StreamingTrackers, ) => { const { name: componentName, throwJsErrors, domNodeId, railsContext } = options; const renderState: StreamRenderState = { @@ -164,7 +173,7 @@ const streamRenderReactComponent = ( endStream(); }; - assertRailsContextWithServerComponentCapabilities(railsContext); + assertRailsContextWithServerStreamingCapabilities(railsContext); Promise.resolve(reactRenderingResult) .then((reactRenderedElement) => { @@ -186,15 +195,13 @@ const streamRenderReactComponent = ( }, onShellReady() { renderState.isShellReady = true; - pipeToTransform(injectRSCPayload(renderingStream, railsContext)); + pipeToTransform(injectRSCPayload(renderingStream, streamingTrackers.rscRequestTracker, domNodeId)); }, onError(e) { reportError(convertToError(e)); }, onAllReady() { - if (railsContext.componentSpecificMetadata?.renderRequestId) { - notifySSREnd(railsContext); - } + streamingTrackers.postSSRHookTracker.notifySSREnd(); }, identifierPrefix: domNodeId, }); @@ -207,7 +214,27 @@ const streamRenderReactComponent = ( return readableStream; }; -type StreamRenderer = (reactElement: StreamableComponentResult, options: P) => T; +type StreamRenderer = ( + reactElement: StreamableComponentResult, + options: P, + streamingTrackers: StreamingTrackers, +) => T; + +/** + * This module implements request-scoped tracking for React Server Components (RSC) + * and post-SSR hooks using local tracker instances per request. + * + * DESIGN PRINCIPLES: + * - Each request gets its own PostSSRHookTracker and RSCRequestTracker instances + * - State is automatically garbage collected when request completes + * - No shared state between concurrent requests + * - Simple, predictable cleanup lifecycle + * + * TRACKER RESPONSIBILITIES: + * - PostSSRHookTracker: Manages hooks that run after SSR completes + * - RSCRequestTracker: Handles RSC payload generation and stream tracking + * - Both inject their capabilities into the Rails context for component access + */ export const streamServerRenderedComponent = ( options: P, @@ -215,6 +242,25 @@ export const streamServerRenderedComponent = ( ): T => { const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; + assertRailsContextWithServerComponentMetadata(railsContext); + const postSSRHookTracker = new PostSSRHookTracker(); + const rscRequestTracker = new RSCRequestTracker(railsContext); + const streamingTrackers = { + postSSRHookTracker, + rscRequestTracker, + }; + + const railsContextWithStreamingCapabilities: RailsContextWithServerStreamingCapabilities = { + ...railsContext, + addPostSSRHook: postSSRHookTracker.addPostSSRHook.bind(postSSRHookTracker), + getRSCPayloadStream: rscRequestTracker.getRSCPayloadStream.bind(rscRequestTracker), + }; + + const optionsWithStreamingCapabilities = { + ...options, + railsContext: railsContextWithStreamingCapabilities, + }; + try { const componentObj = ComponentRegistry.get(componentName); validateComponent(componentObj, componentName); @@ -224,7 +270,7 @@ export const streamServerRenderedComponent = ( domNodeId, trace, props, - railsContext, + railsContext: railsContextWithStreamingCapabilities, }); if (isServerRenderHash(reactRenderingResult)) { @@ -242,10 +288,10 @@ export const streamServerRenderedComponent = ( } return result; }); - return renderStrategy(promiseAfterRejectingHash, options); + return renderStrategy(promiseAfterRejectingHash, optionsWithStreamingCapabilities, streamingTrackers); } - return renderStrategy(reactRenderingResult, options); + return renderStrategy(reactRenderingResult, optionsWithStreamingCapabilities, streamingTrackers); } catch (e) { const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject({ hasErrors: true, diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 18b58ec149..a425a2d21b 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -36,15 +36,6 @@ export type RailsContext = { search: string | null; httpAcceptLanguage: string; rscPayloadGenerationUrlPath?: string; - componentSpecificMetadata?: { - // The renderRequestId serves as a unique identifier for each render request. - // We cannot rely solely on nodeDomId, as it should be unique for each component on the page, - // but the server can render the same page multiple times concurrently for different users. - // Therefore, we need an additional unique identifier that can be used both on the client and server. - // This ID can also be used to associate specific data with a particular rendered component - // on either the server or client. - renderRequestId: string; - }; } & ( | { serverSide: false; @@ -60,55 +51,60 @@ export type RailsContext = { serverSideRSCPayloadParameters?: unknown; reactClientManifestFileName?: string; reactServerClientManifestFileName?: string; + getRSCPayloadStream: (componentName: string, props: unknown) => Promise; } ); -export type RailsContextWithComponentSpecificMetadata = RailsContext & { - componentSpecificMetadata: { - renderRequestId: string; - }; -}; - -export type RailsContextWithServerComponentCapabilities = RailsContextWithComponentSpecificMetadata & { +export type RailsContextWithServerComponentMetadata = RailsContext & { serverSide: true; serverSideRSCPayloadParameters?: unknown; reactClientManifestFileName: string; reactServerClientManifestFileName: string; }; -export const assertRailsContextWithComponentSpecificMetadata: ( - context: RailsContext | undefined, -) => asserts context is RailsContextWithComponentSpecificMetadata = ( - context: RailsContext | undefined, -): asserts context is RailsContextWithComponentSpecificMetadata => { - if (!context || !('componentSpecificMetadata' in context)) { - throw new Error( - 'Rails context does not have component specific metadata. Please ensure you are using a compatible version of react_on_rails_pro', - ); - } +export type RailsContextWithServerStreamingCapabilities = RailsContextWithServerComponentMetadata & { + getRSCPayloadStream: (componentName: string, props: unknown) => Promise; + addPostSSRHook: (hook: () => void) => void; }; -export const assertRailsContextWithServerComponentCapabilities: ( +const throwRailsContextMissingEntries = (missingEntries: string) => { + throw new Error( + `Rails context does not have server side ${missingEntries}.\n\n` + + 'Please ensure:\n' + + '1. You are using a compatible version of react_on_rails_pro\n' + + '2. Server components support is enabled by setting:\n' + + ' ReactOnRailsPro.configuration.enable_rsc_support = true', + ); +}; + +export const assertRailsContextWithServerComponentMetadata: ( context: RailsContext | undefined, -) => asserts context is RailsContextWithServerComponentCapabilities = ( +) => asserts context is RailsContextWithServerComponentMetadata = ( context: RailsContext | undefined, -): asserts context is RailsContextWithServerComponentCapabilities => { +): asserts context is RailsContextWithServerComponentMetadata => { if ( !context || !('reactClientManifestFileName' in context) || - !('reactServerClientManifestFileName' in context) || - !('componentSpecificMetadata' in context) + !('reactServerClientManifestFileName' in context) ) { - throw new Error( - 'Rails context does not have server side RSC payload parameters.\n\n' + - 'Please ensure:\n' + - '1. You are using a compatible version of react_on_rails_pro\n' + - '2. Server components support is enabled by setting:\n' + - ' ReactOnRailsPro.configuration.enable_rsc_support = true', + throwRailsContextMissingEntries( + 'server side RSC payload parameters, reactClientManifestFileName, and reactServerClientManifestFileName', ); } }; +export const assertRailsContextWithServerStreamingCapabilities: ( + context: RailsContext | undefined, +) => asserts context is RailsContextWithServerStreamingCapabilities = ( + context: RailsContext | undefined, +): asserts context is RailsContextWithServerStreamingCapabilities => { + assertRailsContextWithServerComponentMetadata(context); + + if (!('getRSCPayloadStream' in context) || !('addPostSSRHook' in context)) { + throwRailsContextMissingEntries('getRSCPayloadStream and addPostSSRHook functions'); + } +}; + // not strictly what we want, see https://github.com/microsoft/TypeScript/issues/17867#issuecomment-323164375 type AuthenticityHeaders = Record & { 'X-CSRF-Token': string | null; @@ -228,7 +224,7 @@ export interface RenderParams extends Params { } export interface RSCRenderParams extends Omit { - railsContext: RailsContextWithServerComponentCapabilities; + railsContext: RailsContextWithServerStreamingCapabilities; reactClientManifestFileName: string; } @@ -346,7 +342,6 @@ export interface ReactOnRails { * Adds a post SSR hook to be called after the SSR has completed. * @param hook - The hook to be called after the SSR has completed. */ - addPostSSRHook(railsContext: RailsContextWithServerComponentCapabilities, hook: () => void): void; } export type RSCPayloadStreamInfo = { @@ -465,47 +460,6 @@ export interface ReactOnRailsInternal extends ReactOnRails { * Indicates if the RSC bundle is being used. */ isRSCBundle: boolean; - - // These functions are intended for use in Node.js environments only. They should be used on the server side and excluded from client-side bundles to reduce bundle size. - /** - * Generates a ReadableStream for a given component's RSC payload. - * @param componentName - The name of the component. - * @param props - The properties to pass to the component. - * @param railsContext - The Rails context of the current rendering request. - * @returns A promise that resolves to a NodeJS.ReadableStream. - */ - getRSCPayloadStream?: ( - componentName: string, - props: unknown, - railsContext: RailsContextWithServerComponentCapabilities, - ) => Promise; - - /** - * Retrieves all React Server Component (RSC) payload streams generated for a specific rendering request. - * @param railsContext - The Rails context of the current rendering request. - * @returns An array of objects, each containing the component name and its corresponding NodeJS.ReadableStream. - */ - getRSCPayloadStreams?: (railsContext: RailsContextWithServerComponentCapabilities) => { - componentName: string; - props: unknown; - stream: NodeJS.ReadableStream; - }[]; - - /** - * Registers a callback to be called when an RSC payload stream is generated for a specific rendering request. - * @param railsContext - The Rails context of the current rendering request. - * @param callback - The callback to be called when an RSC payload stream is generated. - */ - onRSCPayloadGenerated?: ( - railsContext: RailsContextWithServerComponentCapabilities, - callback: RSCPayloadCallback, - ) => void; - - /** - * Clears all RSC payload streams generated for the rendering request of the given Rails context. - * @param railsContext - The Rails context of the current rendering request. - */ - clearRSCPayloadStreams?: (railsContext: RailsContextWithServerComponentCapabilities) => void; } export type RenderStateHtml = FinalHtmlResult | Promise; diff --git a/node_package/src/utils.ts b/node_package/src/utils.ts index 09855a3c3d..c8093c7ae6 100644 --- a/node_package/src/utils.ts +++ b/node_package/src/utils.ts @@ -1,5 +1,3 @@ -import { RailsContextWithComponentSpecificMetadata } from './types/index.ts'; - // Override the fetch function to make it easier to test // The default fetch implementation in jest returns Node's Readable stream // In jest.setup.js, we configure this fetch to return a web-standard ReadableStream instead, @@ -12,12 +10,19 @@ const customFetch = (...args: Parameters) => { export { customFetch as fetch }; -export const createRSCPayloadKey = ( - componentName: string, - componentProps: unknown, - railsContext: RailsContextWithComponentSpecificMetadata, -) => { - return `${componentName}-${JSON.stringify(componentProps)}-${railsContext.componentSpecificMetadata.renderRequestId}`; +/** + * Creates a unique cache key for RSC payloads. + * + * This function generates cache keys that ensure: + * 1. Different components have different keys + * 2. Same components with different props have different keys + * + * @param componentName - Name of the React Server Component + * @param componentProps - Props passed to the component (serialized to JSON) + * @returns A unique cache key string + */ +export const createRSCPayloadKey = (componentName: string, componentProps: unknown, domNodeId?: string) => { + return `${componentName}-${JSON.stringify(componentProps)}${domNodeId ? `-${domNodeId}` : ''}`; }; /** @@ -35,3 +40,7 @@ export const wrapInNewPromise = (promise: Promise) => { void promise.catch(reject); }); }; + +export const extractErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; diff --git a/node_package/src/wrapServerComponentRenderer/client.tsx b/node_package/src/wrapServerComponentRenderer/client.tsx index e7feb97bca..491b7866b0 100644 --- a/node_package/src/wrapServerComponentRenderer/client.tsx +++ b/node_package/src/wrapServerComponentRenderer/client.tsx @@ -1,10 +1,6 @@ import * as React from 'react'; import * as ReactDOMClient from 'react-dom/client'; -import { - ReactComponentOrRenderFunction, - RenderFunction, - assertRailsContextWithComponentSpecificMetadata, -} from '../types/index.ts'; +import { ReactComponentOrRenderFunction, RenderFunction } from '../types/index.ts'; import isRenderFunction from '../isRenderFunction.ts'; import { ensureReactUseAvailable } from '../reactApis.cts'; import { createRSCProvider } from '../RSCProvider.tsx'; @@ -53,11 +49,12 @@ const wrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOr throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`); } - assertRailsContextWithComponentSpecificMetadata(railsContext); + if (!railsContext) { + throw new Error('RSCClientRoot: No railsContext provided'); + } const RSCProvider = createRSCProvider({ - railsContext, - getServerComponent: getReactServerComponent, + getServerComponent: getReactServerComponent(domNodeId, railsContext), }); const root = ( diff --git a/node_package/src/wrapServerComponentRenderer/server.tsx b/node_package/src/wrapServerComponentRenderer/server.tsx index d92cc04a3a..f4c022c474 100644 --- a/node_package/src/wrapServerComponentRenderer/server.tsx +++ b/node_package/src/wrapServerComponentRenderer/server.tsx @@ -3,7 +3,7 @@ import type { RenderFunction, ReactComponentOrRenderFunction } from '../types/in import getReactServerComponent from '../getReactServerComponent.server.ts'; import { createRSCProvider } from '../RSCProvider.tsx'; import isRenderFunction from '../isRenderFunction.ts'; -import { assertRailsContextWithServerComponentCapabilities } from '../types/index.ts'; +import { assertRailsContextWithServerStreamingCapabilities } from '../types/index.ts'; /** * Wraps a client component with the necessary RSC context and handling for server-side operations. @@ -30,7 +30,7 @@ const wrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOr } const wrapper: RenderFunction = async (props, railsContext) => { - assertRailsContextWithServerComponentCapabilities(railsContext); + assertRailsContextWithServerStreamingCapabilities(railsContext); const Component = isRenderFunction(componentOrRenderFunction) ? await componentOrRenderFunction(props, railsContext) @@ -41,8 +41,7 @@ const wrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOr } const RSCProvider = createRSCProvider({ - railsContext, - getServerComponent: getReactServerComponent, + getServerComponent: getReactServerComponent(railsContext), }); return () => ( diff --git a/node_package/tests/injectRSCPayload.test.ts b/node_package/tests/injectRSCPayload.test.ts index 924a0b4b50..b13db1c955 100644 --- a/node_package/tests/injectRSCPayload.test.ts +++ b/node_package/tests/injectRSCPayload.test.ts @@ -1,6 +1,7 @@ import { Readable, PassThrough } from 'stream'; -import ReactOnRails, { RailsContextWithServerComponentCapabilities } from '../src/ReactOnRails.node.ts'; +import { RailsContextWithServerStreamingCapabilities } from '../src/types/index.ts'; import injectRSCPayload from '../src/injectRSCPayload.ts'; +import RSCRequestTracker from '../src/RSCRequestTracker.ts'; // Shared utilities const createMockStream = (chunks: (string | Buffer)[] | { [key: number]: string | string[] }) => { @@ -36,46 +37,42 @@ const collectStreamData = async (stream: Readable) => { // Test setup helper const setupTest = (mockRSC: Readable) => { - jest.spyOn(ReactOnRails, 'onRSCPayloadGenerated').mockImplementation((_railsContext, callback) => { + const railsContext = {} as RailsContextWithServerStreamingCapabilities; + const rscRequestTracker = new RSCRequestTracker(railsContext); + jest.spyOn(rscRequestTracker, 'onRSCPayloadGenerated').mockImplementation((callback) => { callback({ stream: mockRSC, componentName: 'test', props: {} }); }); - const railsContext = { - componentSpecificMetadata: { - renderRequestId: '123', - }, - } as RailsContextWithServerComponentCapabilities; - - return { railsContext }; + return { railsContext, rscRequestTracker, domNodeId: 'test-node' }; }; describe('injectRSCPayload', () => { it('should inject RSC payload as script tags', async () => { const mockRSC = createMockStream(['{"test": "data"}']); const mockHTML = createMockStream(['
Hello, world!
']); - const { railsContext } = setupTest(mockRSC); + const { rscRequestTracker, domNodeId } = setupTest(mockRSC); - const result = injectRSCPayload(mockHTML, railsContext); + const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId); const resultStr = await collectStreamData(result); expect(resultStr).toContain( - ``, + ``, ); }); it('should handle multiple RSC payloads', async () => { const mockRSC = createMockStream(['{"test": "data"}', '{"test": "data2"}']); const mockHTML = createMockStream(['
Hello, world!
']); - const { railsContext } = setupTest(mockRSC); + const { rscRequestTracker, domNodeId } = setupTest(mockRSC); - const result = injectRSCPayload(mockHTML, railsContext); + const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId); const resultStr = await collectStreamData(result); expect(resultStr).toContain( - ``, + ``, ); expect(resultStr).toContain( - ``, + ``, ); }); @@ -85,17 +82,17 @@ describe('injectRSCPayload', () => { '
Hello, world!
', '
Next chunk
', ]); - const { railsContext } = setupTest(mockRSC); + const { rscRequestTracker, domNodeId } = setupTest(mockRSC); - const result = injectRSCPayload(mockHTML, railsContext); + const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId); const resultStr = await collectStreamData(result); expect(resultStr).toEqual( - '' + + '' + '
Hello, world!
' + '
Next chunk
' + - '' + - '', + '' + + '', ); }); @@ -105,16 +102,16 @@ describe('injectRSCPayload', () => { 0: '
Hello, world!
', 100: '
Next chunk
', }); - const { railsContext } = setupTest(mockRSC); + const { rscRequestTracker, domNodeId } = setupTest(mockRSC); - const result = injectRSCPayload(mockHTML, railsContext); + const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId); const resultStr = await collectStreamData(result); expect(resultStr).toEqual( - '' + + '' + '
Hello, world!
' + - '' + - '' + + '' + + '' + '
Next chunk
', ); }); @@ -128,17 +125,17 @@ describe('injectRSCPayload', () => { 100: ['
Hello, world!
', '
Next chunk
'], 200: '
Third chunk
', }); - const { railsContext } = setupTest(mockRSC); + const { rscRequestTracker, domNodeId } = setupTest(mockRSC); - const result = injectRSCPayload(mockHTML, railsContext); + const result = injectRSCPayload(mockHTML, rscRequestTracker, domNodeId); const resultStr = await collectStreamData(result); expect(resultStr).toEqual( - '' + + '' + '
Hello, world!
' + '
Next chunk
' + - '' + - '' + + '' + + '' + '
Third chunk
', ); }); diff --git a/node_package/tests/registerServerComponent.client.test.jsx b/node_package/tests/registerServerComponent.client.test.jsx index ebd6a3c6c4..e9bad6cf8f 100644 --- a/node_package/tests/registerServerComponent.client.test.jsx +++ b/node_package/tests/registerServerComponent.client.test.jsx @@ -64,9 +64,6 @@ enableFetchMocks(); registerServerComponent('TestComponent'); const railsContext = { rscPayloadGenerationUrlPath, - componentSpecificMetadata: { - renderRequestId: 'test-render-request-id', - }, }; // Execute the render @@ -173,9 +170,6 @@ enableFetchMocks(); registerServerComponent('TestComponent'); railsContext = { rscPayloadGenerationUrlPath: 'rsc-render', - componentSpecificMetadata: { - renderRequestId: 'test-render-request-id', - }, }; // Cleanup any previous payload data @@ -190,7 +184,7 @@ enableFetchMocks(); it('uses preloaded RSC payloads without making a fetch request', async () => { // Mock the global window.REACT_ON_RAILS_RSC_PAYLOADS window.REACT_ON_RAILS_RSC_PAYLOADS = { - 'TestComponent-{}-test-render-request-id': [`${chunk1}\n`, `${chunk2}\n`], + 'TestComponent-{}-test-container': [`${chunk1}\n`, `${chunk2}\n`], }; await act(async () => { @@ -214,7 +208,7 @@ enableFetchMocks(); // Mock the global window.REACT_ON_RAILS_RSC_PAYLOADS with only the first chunk initially window.REACT_ON_RAILS_RSC_PAYLOADS = { - 'TestComponent-{}-test-render-request-id': [`${chunk1}\n`], + 'TestComponent-{}-test-container': [`${chunk1}\n`], }; await act(async () => { @@ -232,7 +226,7 @@ enableFetchMocks(); // Now push the second chunk to the preloaded array and set document to complete await act(async () => { - window.REACT_ON_RAILS_RSC_PAYLOADS['TestComponent-{}-test-render-request-id'].push(`${chunk2}\n`); + window.REACT_ON_RAILS_RSC_PAYLOADS['TestComponent-{}-test-container'].push(`${chunk2}\n`); // Set document.readyState to 'complete' and dispatch readystatechange event Object.defineProperty(document, 'readyState', { value: 'complete', writable: true }); diff --git a/node_package/tests/streamServerRenderedReactComponent.test.jsx b/node_package/tests/streamServerRenderedReactComponent.test.jsx index ff850fd795..7fb0421869 100644 --- a/node_package/tests/streamServerRenderedReactComponent.test.jsx +++ b/node_package/tests/streamServerRenderedReactComponent.test.jsx @@ -10,7 +10,7 @@ import ReactOnRails from '../src/ReactOnRails.node.ts'; const AsyncContent = async ({ throwAsyncError }) => { await new Promise((resolve) => { - setTimeout(resolve, 10); + setTimeout(resolve, 50); }); if (throwAsyncError) { throw new Error('Async Error');