diff --git a/node_package/src/isServerRenderResult.ts b/node_package/src/isServerRenderResult.ts index 3b7db625a..88f9cf5be 100644 --- a/node_package/src/isServerRenderResult.ts +++ b/node_package/src/isServerRenderResult.ts @@ -9,7 +9,7 @@ export function isServerRenderHash(testValue: CreateReactOutputResult): (testValue as ServerRenderResult).error); } -export function isPromise(testValue: CreateReactOutputResult): - testValue is Promise { - return !!((testValue as Promise).then); +export function isPromise(testValue: CreateReactOutputResult | Promise | string | null): + testValue is Promise { + return !!((testValue as Promise | null)?.then); } diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 5a518e3bf..fec68e231 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -6,154 +6,180 @@ import createReactOutput from './createReactOutput'; import { isPromise, isServerRenderHash } from './isServerRenderResult'; import buildConsoleReplay from './buildConsoleReplay'; import handleError from './handleError'; -import type { RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types'; +import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types'; -/* eslint-disable @typescript-eslint/no-explicit-any */ +type RenderState = { + result: null | string | Promise; + hasErrors: boolean; + error?: RenderingError; +}; -function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise { - const { name, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options; +type RenderOptions = { + componentName: string; + domNodeId?: string; + trace?: boolean; + renderingReturnsPromises: boolean; +}; - let renderResult: null | string | Promise = null; - let hasErrors = false; - let renderingError: null | RenderingError = null; +function validateComponent(componentObj: RegisteredComponent, componentName: string) { + if (componentObj.isRenderer) { + throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`); + } +} - try { - const componentObj = ComponentRegistry.get(name); - if (componentObj.isRenderer) { - throw new Error(`\ -Detected a renderer while server rendering component '${name}'. \ -See https://github.com/shakacode/react_on_rails#renderer-functions`); +function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState { + const { redirectLocation, routeError } = result; + const hasErrors = !!routeError; + + if (hasErrors) { + console.error(`React Router ERROR: ${JSON.stringify(routeError)}`); + } + + let htmlResult: string; + if (redirectLocation) { + if (options.trace) { + const redirectPath = redirectLocation.pathname + redirectLocation.search; + console.log(`ROUTER REDIRECT: ${options.componentName} to dom node with id: ${options.domNodeId}, redirect to ${redirectPath}`); } + // For redirects on server rendering, we can't stop Rails from returning the same result. + // Possibly, someday, we could have the Rails server redirect. + htmlResult = ''; + } else { + htmlResult = result.renderedHtml as string; + } - const reactRenderingResult = createReactOutput({ - componentObj, - domNodeId, - trace, - props, - railsContext, - }); - - const processServerRenderHash = () => { - // We let the client side handle any redirect - // Set hasErrors in case we want to throw a Rails exception - const { redirectLocation, routeError } = reactRenderingResult as ServerRenderResult; - hasErrors = !!routeError; - - if (hasErrors) { - console.error( - `React Router ERROR: ${JSON.stringify(routeError)}`, - ); - } - - if (redirectLocation) { - if (trace) { - const redirectPath = redirectLocation.pathname + redirectLocation.search; - console.log(`\ - ROUTER REDIRECT: ${name} to dom node with id: ${domNodeId}, redirect to ${redirectPath}`, - ); - } - // For redirects on server rendering, we can't stop Rails from returning the same result. - // Possibly, someday, we could have the rails server redirect. - return ''; - } - return (reactRenderingResult as ServerRenderResult).renderedHtml as string; - }; - - const processPromise = () => { - if (!renderingReturnsPromises) { - console.error('Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.'); - } - return reactRenderingResult; - }; - - const processReactElement = () => { - try { - return ReactDOMServer.renderToString(reactRenderingResult as ReactElement); - } catch (error) { - console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already + return { result: htmlResult, hasErrors }; +} + +function processPromise(result: Promise, renderingReturnsPromises: boolean): Promise | string { + if (!renderingReturnsPromises) { + console.error('Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.'); + // If the app is using server rendering with ExecJS, then the promise will not be awaited. + // And when a promise is passed to JSON.stringify, it will be converted to '{}'. + return '{}'; + } + return result; +} + +function processReactElement(result: ReactElement): string { + try { + return ReactDOMServer.renderToString(result); + } catch (error) { + console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already calls renderToString, that takes one parameter. You need to add an extra unused parameter to identify this function as a renderFunction and not a simple React Function Component.`); - throw error; - } - }; - - if (isServerRenderHash(reactRenderingResult)) { - renderResult = processServerRenderHash(); - } else if (isPromise(reactRenderingResult)) { - renderResult = processPromise() as Promise; - } else { - renderResult = processReactElement(); - } - } catch (e: any) { - if (throwJsErrors) { - throw e; - } + throw error; + } +} + +function processRenderingResult(result: CreateReactOutputResult, options: RenderOptions): RenderState { + if (isServerRenderHash(result)) { + return processServerRenderHash(result, options); + } + if (isPromise(result)) { + return { result: processPromise(result, options.renderingReturnsPromises), hasErrors: false }; + } + return { result: processReactElement(result), hasErrors: false }; +} - hasErrors = true; - renderResult = handleError({ - e, - name, - serverSide: true, - }); - renderingError = e; +function handleRenderingError(e: unknown, options: { componentName: string, throwJsErrors: boolean }) { + if (options.throwJsErrors) { + throw e; } + const error = e instanceof Error ? e : new Error(String(e)); + return { + hasErrors: true, + result: handleError({ e: error, name: options.componentName, serverSide: true }), + error, + }; +} - const consoleReplayScript = buildConsoleReplay(); - const addRenderingErrors = (resultObject: RenderResult, renderError: RenderingError) => { - // Do not use `resultObject.renderingError = renderError` because JSON.stringify will turn it into '{}'. - resultObject.renderingError = { // eslint-disable-line no-param-reassign - message: renderError.message, - stack: renderError.stack, - }; +function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState): RenderResult { + return { + html, + consoleReplayScript, + hasErrors: renderState.hasErrors, + renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack }, }; +} - if (renderingReturnsPromises) { - const resolveRenderResult = async () => { - let promiseResult: RenderResult; - - try { - promiseResult = { - html: await renderResult, - consoleReplayScript, - hasErrors, - }; - } catch (e: any) { - if (throwJsErrors) { - throw e; - } - promiseResult = { - html: handleError({ - e, - name, - serverSide: true, - }), - consoleReplayScript, - hasErrors: true, - }; - renderingError = e; - } - - if (renderingError !== null) { - addRenderingErrors(promiseResult, renderingError); - } - - return promiseResult; - }; - - return resolveRenderResult(); +async function createPromiseResult( + renderState: RenderState & { result: Promise }, + consoleReplayScript: string, + componentName: string, + throwJsErrors: boolean +): Promise { + try { + const html = await renderState.result; + return createResultObject(html, consoleReplayScript, renderState); + } catch (e: unknown) { + const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors }); + return createResultObject(errorRenderState.result, consoleReplayScript, errorRenderState); } +} - const result: RenderResult = { - html: renderResult as string, - consoleReplayScript, - hasErrors, +function createFinalResult( + renderState: RenderState, + componentName: string, + throwJsErrors: boolean +): null | string | Promise { + // Console history is stored globally in `console.history`. + // If node renderer is handling a render request that returns a promise, + // It can handle another request while awaiting the promise. + // To prevent cross-request console logs leakage between these requests, + // we build the consoleReplayScript before awaiting any promises. + // The console history is reset after the synchronous part of each request. + // This causes console logs happening during async operations to not be captured. + const consoleReplayScript = buildConsoleReplay(); + + const { result } = renderState; + if (isPromise(result)) { + return createPromiseResult({ ...renderState, result }, consoleReplayScript, componentName, throwJsErrors); + } + + return JSON.stringify(createResultObject(result, consoleReplayScript, renderState)); +} + +function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise { + const { name: componentName, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options; + + let renderState: RenderState = { + result: null, + hasErrors: false, }; - if (renderingError) { - addRenderingErrors(result, renderingError); + try { + const componentObj = ComponentRegistry.get(componentName); + validateComponent(componentObj, componentName); + + // Renders the component or executes the render function + // - If the registered component is a React element or component, it renders it + // - If it's a render function, it executes the function and processes the result: + // - For React elements or components, it renders them + // - For promises, it returns them without awaiting (for async rendering) + // - For other values (e.g., strings), it returns them directly + // Note: Only synchronous operations are performed at this stage + const reactRenderingResult = createReactOutput({ componentObj, domNodeId, trace, props, railsContext }); + + // Processes the result from createReactOutput: + // 1. Converts React elements to HTML strings + // 2. Returns rendered HTML from serverRenderHash + // 3. Handles promises for async rendering + renderState = processRenderingResult(reactRenderingResult, { componentName, domNodeId, trace, renderingReturnsPromises }); + } catch (e: unknown) { + renderState = handleRenderingError(e, { componentName, throwJsErrors }); } - return JSON.stringify(result); + // Finalize the rendering result and prepare it for server response + // 1. Builds the consoleReplayScript for client-side console replay + // 2. Extract the result from promise (if needed) by awaiting it + // 3. Constructs a JSON object with the following properties: + // - html: string | null (The rendered component HTML) + // - consoleReplayScript: string (Script to replay console outputs on the client) + // - hasErrors: boolean (Indicates if any errors occurred during rendering) + // - renderingError: Error | null (The error object if an error occurred, null otherwise) + // 4. For Promise results, it awaits resolution before creating the final JSON + return createFinalResult(renderState, componentName, throwJsErrors); } const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (options) => { @@ -165,4 +191,5 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o console.history = []; } }; + export default serverRenderReactComponent; diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index a952b4b15..d2e129db5 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -43,6 +43,30 @@ type CreateReactOutputResult = ServerRenderResult | ReactElement | Promise; +/** + * Render functions are used to create dynamic React components or server-rendered HTML with side effects. + * They receive two arguments: props and railsContext. + * + * @param props - The component props passed to the render function + * @param railsContext - The Rails context object containing environment information + * @returns A string, React component, React element, or a Promise resolving to a string + * + * @remarks + * To distinguish a render function from a React Function Component: + * 1. Ensure it accepts two parameters (props and railsContext), even if railsContext is unused, or + * 2. Set the `renderFunction` property to `true` on the function object. + * + * If neither condition is met, it will be treated as a React Function Component, + * and ReactDOMServer will attempt to render it. + * + * @example + * // Option 1: Two-parameter function + * const renderFunction = (props, railsContext) => { ... }; + * + * // Option 2: Using renderFunction property + * const anotherRenderFunction = (props) => { ... }; + * anotherRenderFunction.renderFunction = true; + */ interface RenderFunction { (props?: any, railsContext?: RailsContext, domNodeId?: string): RenderFunctionResult; // We allow specifying that the function is RenderFunction and not a React Function Component @@ -67,7 +91,15 @@ export type { // eslint-disable-line import/prefer-default-export export interface RegisteredComponent { name: string; component: ReactComponentOrRenderFunction; + /** + * Indicates if the registered component is a RenderFunction + * @see RenderFunction for more details on its behavior and usage. + */ renderFunction: boolean; + // Indicates if the registered component is a Renderer function. + // Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId) + // Supported on the client side only. + // All renderer functions are render functions, but not all render functions are renderer functions. isRenderer: boolean; }