shakacode/react_on_rails

View on GitHub
node_package/src/serverRenderReactComponent.ts

Summary

Maintainability
A
0 mins
Test Coverage
import ReactDOMServer from 'react-dom/server';
import type { ReactElement } from 'react';

import ComponentRegistry from './ComponentRegistry';
import createReactOutput from './createReactOutput';
import { isPromise, isServerRenderHash } from './isServerRenderResult';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types';

type RenderState = {
  result: null | string | Promise<string>;
  hasErrors: boolean;
  error?: RenderingError;
};

type RenderOptions = {
  componentName: string;
  domNodeId?: string;
  trace?: boolean;
  renderingReturnsPromises: boolean;
};

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`);
  }
}

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;
  }

  return { result: htmlResult, hasErrors };
}

function processPromise(result: Promise<string>, renderingReturnsPromises: boolean): Promise<string> | 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;
  }
}

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 };
}

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,
  };
}

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 },
  };
}

async function createPromiseResult(
  renderState: RenderState & { result: Promise<string> },
  componentName: string,
  throwJsErrors: boolean
): Promise<RenderResult> {
  // Capture console history before awaiting the promise
  // Node renderer will reset the global console.history after executing the synchronous part of the request.
  // It resets it only if replayServerAsyncOperationLogs renderer config is set to false.
  // In both cases, we need to keep a reference to console.history to avoid losing console logs in case of reset.
  const consoleHistory = console.history;
  try {
    const html = await renderState.result;
    const consoleReplayScript = buildConsoleReplay(consoleHistory);
    return createResultObject(html, consoleReplayScript, renderState);
  } catch (e: unknown) {
    const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors });
    const consoleReplayScript = buildConsoleReplay(consoleHistory);
    return createResultObject(errorRenderState.result, consoleReplayScript, renderState);
  }
}

function createFinalResult(
  renderState: RenderState,
  componentName: string,
  throwJsErrors: boolean
): null | string | Promise<RenderResult> {
  const { result } = renderState;
  if (isPromise(result)) {
    return createPromiseResult({ ...renderState, result }, componentName, throwJsErrors);
  }

  const consoleReplayScript = buildConsoleReplay();
  return JSON.stringify(createResultObject(result, consoleReplayScript, renderState));
}

function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise<RenderResult> {
  const { name: componentName, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options;

  let renderState: RenderState = {
    result: null,
    hasErrors: false,
  };

  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 });
  }

  // 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) => {
  try {
    return serverRenderReactComponentInternal(options);
  } finally {
    // Reset console history after each render.
    // See `RubyEmbeddedJavaScript.console_polyfill` for initialization.
    // This is necessary when ExecJS and old versions of node renderer are used.
    // New versions of node renderer reset the console history automatically.
    console.history = [];
  }
};

export default serverRenderReactComponent;