diff --git a/CHANGELOG.md b/CHANGELOG.md
index c94aba1daf..eac41e24e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,8 @@ Changes since the last non-beta release.
- Configuration option `generated_component_packs_loading_strategy` to control how generated component packs are loaded. It supports `sync`, `async`, and `defer` strategies. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
+- Support for returning React component from async render-function. [PR 1720](https://github.com/shakacode/react_on_rails/pull/1720) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
+
### Removed (Breaking Changes)
- Deprecated `defer_generated_component_packs` configuration option. You should use `generated_component_packs_loading_strategy` instead. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
diff --git a/docs/javascript/render-functions.md b/docs/javascript/render-functions.md
new file mode 100644
index 0000000000..1d9bbed81e
--- /dev/null
+++ b/docs/javascript/render-functions.md
@@ -0,0 +1,343 @@
+# React on Rails Render-Functions: Usage Guide
+
+This guide explains how render-functions work in React on Rails and how to use them with Ruby helper methods.
+
+## Types of Render-Functions and Their Return Values
+
+Render-functions take two parameters:
+
+1. `props`: The props passed from the Ruby helper methods (via the `props:` parameter), which become available in your JavaScript.
+2. `railsContext`: Rails contextual information like current pathname, locale, etc. See the [Render-Functions and the Rails Context](https://www.shakacode.com/react-on-rails/docs/guides/render-functions-and-railscontext/) documentation for more details.
+
+### Identifying Render-Functions
+
+React on Rails needs to identify which functions are render-functions (as opposed to regular React components). There are two ways to mark a function as a render function:
+
+1. Accept two parameters in your function definition: `(props, railsContext)` - React on Rails will detect this signature (the parameter names don't matter).
+2. Add a `renderFunction = true` property to your function - This is useful when your function doesn't need the railsContext.
+
+```jsx
+// Method 1: Use signature with two parameters
+const MyComponent = (props, railsContext) => {
+ return () => (
+
+ Hello {props.name} from {railsContext.pathname}
+
+ );
+};
+
+// Method 2: Use renderFunction property
+const MyOtherComponent = (props) => {
+ return () => Hello {props.name}
;
+};
+MyOtherComponent.renderFunction = true;
+
+ReactOnRails.register({ MyComponent, MyOtherComponent });
+```
+
+Render-functions can return several types of values:
+
+### 1. React Components
+
+```jsx
+const MyComponent = (props, _railsContext) => {
+ // The `props` parameter here is identical to the `props` passed from the Ruby helper methods (via the `props:` parameter).
+ // Both `props` and `reactProps` refer to the same object.
+ return (reactProps) => Hello {props.name}
;
+};
+```
+
+> [!NOTE]
+> Ensure to return a React component (a function or class) and not a React element (the result of calling `React.createElement` or JSX).
+
+### 2. Objects with `renderedHtml` string property
+
+```jsx
+const MyComponent = (props, _railsContext) => {
+ return {
+ renderedHtml: `Hello ${props.name}
`,
+ };
+};
+```
+
+### 3. Objects with `renderedHtml` as object containing `componentHtml` and other properties if needed (server-side hash)
+
+```jsx
+const MyComponent = (props, _railsContext) => {
+ return {
+ renderedHtml: {
+ componentHtml: Hello {props.name}
,
+ title: `${props.title}`,
+ metaTags: ``,
+ },
+ };
+};
+```
+
+### 4. Promises of Strings
+
+This and other promise options below are only available in React on Rails Pro with the Node renderer.
+
+```jsx
+const MyComponent = async (props, _railsContext) => {
+ const data = await fetchData();
+ return `Hello ${data.name}
`;
+};
+```
+
+### 5. Promises of server-side hash
+
+```jsx
+const MyComponent = async (props, _railsContext) => {
+ const data = await fetchData();
+ return {
+ componentHtml: `Hello ${data.name}
`,
+ title: `${data.title}`,
+ metaTags: ``,
+ };
+};
+```
+
+### 6. Promises of React Components
+
+```jsx
+const MyComponent = async (props, _railsContext) => {
+ const data = await fetchData();
+ return () => Hello {data.name}
;
+};
+```
+
+### 7. Redirect Information
+
+> [!NOTE]
+> React on Rails does not perform actual page redirections. Instead, it returns an empty component and relies on the front end to handle the redirection when the router is rendered. The `redirectLocation` property is logged in the console and ignored by the server renderer. If the `routeError` property is not null or undefined, it is logged and will cause Ruby to throw a `ReactOnRails::PrerenderError` if the `raise_on_prerender_error` configuration is enabled.
+
+```jsx
+const MyComponent = (props, _railsContext) => {
+ return {
+ redirectLocation: { pathname: '/new-path', search: '' },
+ routeError: null,
+ };
+};
+```
+
+## Important Rendering Behavior
+
+Take a look at [serverRenderReactComponent.test.ts](https://github.com/shakacode/react_on_rails/blob/master/node_package/tests/serverRenderReactComponent.test.ts):
+
+1. **Direct String Returns Don't Work** - Returning a raw HTML string directly from a render function causes an error. Always wrap HTML strings in `{ renderedHtml: '...' }`.
+
+2. **Objects Require Specific Properties** - Non-promise objects must include a `renderedHtml` property to be valid when used with `react_component`.
+
+3. **Async Functions Support All Return Types** - Async functions can return React components, strings, or objects with any property structure due to special handling in the server renderer, but it doesn't support properties like `redirectLocation` and `routeError` that can be returned by sync render function. See [7. Redirect Information](#7-redirect-information).
+
+## Ruby Helper Functions
+
+### 1. react_component
+
+The `react_component` helper renders a single React component in your view.
+
+```ruby
+<%= react_component("MyComponent", props: { name: "John" }) %>
+```
+
+This helper accepts render-functions that return React components, objects with a `renderedHtml` property, or promises that resolve to React components, or strings.
+
+#### When to use:
+
+- When you need to render a single component
+- When you're rendering client-side only
+- When your render function returns a single HTML string
+
+#### Not suitable for:
+
+- When your render function returns an object with multiple HTML strings
+- When you need to insert content in different parts of the page, such as meta tags & style tags
+
+### 2. react_component_hash
+
+The `react_component_hash` helper is used when your render function returns an object with multiple HTML strings. It allows you to place different parts of the rendered output in different parts of your layout.
+
+```ruby
+# With a render function that returns an object with multiple HTML properties
+<% helmet_data = react_component_hash("HelmetComponent", props: {
+ title: "My Page",
+ description: "Page description"
+}) %>
+
+<% content_for :head do %>
+ <%= helmet_data["title"] %>
+ <%= helmet_data["metaTags"] %>
+<% end %>
+
+
+ <%= helmet_data["componentHtml"] %>
+
+```
+
+This helper accepts render-functions that return objects with a `renderedHtml` property containing `componentHtml` and any other necessary properties. It also supports promises that resolve to a server-side hash.
+
+#### When to use:
+
+- When your render function returns multiple HTML strings in an object
+- When you need to insert rendered content in different parts of your page
+- For SEO-related rendering like meta tags and title tags
+- When working with libraries like React Helmet
+
+#### Not suitable for:
+
+- Simple component rendering
+- Client-side only rendering (always uses server rendering)
+
+#### Requirements:
+
+- The render function MUST return an object
+- The object MUST include a `componentHtml` key
+- All other keys are optional and can be accessed in your Rails view
+
+## Examples with Appropriate Helper Methods
+
+### Return Type 1: React Component
+
+```jsx
+const SimpleComponent = (props, _railsContext) => () => Hello {props.name}
;
+ReactOnRails.register({ SimpleComponent });
+```
+
+```erb
+<%# Ruby %>
+<%= react_component("SimpleComponent", props: { name: "John" }) %>
+```
+
+### Return Type 2: Object with renderedHtml
+
+```jsx
+const RenderedHtmlComponent = (props, _railsContext) => {
+ return { renderedHtml: `Hello ${props.name}
` };
+};
+ReactOnRails.register({ RenderedHtmlComponent });
+```
+
+```erb
+<%# Ruby %>
+<%= react_component("RenderedHtmlComponent", props: { name: "John" }) %>
+```
+
+### Return Type 3: Object with server-side hash
+
+```jsx
+const HelmetComponent = (props) => {
+ return {
+ renderedHtml: {
+ componentHtml: Hello {props.name}
,
+ title: `${props.title}`,
+ metaTags: ``,
+ },
+ };
+};
+// The render function should either:
+// 1. Accept two arguments: (props, railsContext)
+// 2. Have a property `renderFunction` set to true
+HelmetComponent.renderFunction = true;
+ReactOnRails.register({ HelmetComponent });
+```
+
+```erb
+<%# Ruby - MUST use react_component_hash %>
+<% helmet_data = react_component_hash("HelmetComponent",
+ props: { name: "John", title: "My Page", description: "Page description" }) %>
+
+<% content_for :head do %>
+ <%= helmet_data["title"] %>
+ <%= helmet_data["metaTags"] %>
+<% end %>
+
+
+ <%= helmet_data["componentHtml"] %>
+
+```
+
+### Return Type 4: Promise of String
+
+```jsx
+const AsyncStringComponent = async (props) => {
+ const data = await fetchData();
+ return `Hello ${data.name}
`;
+};
+AsyncStringComponent.renderFunction = true;
+ReactOnRails.register({ AsyncStringComponent });
+```
+
+```erb
+<%# Ruby %>
+<%= react_component("AsyncStringComponent", props: { dataUrl: "/api/data" }) %>
+```
+
+### Return Type 5: Promise of server-side hash
+
+```jsx
+const AsyncObjectComponent = async (props) => {
+ const data = await fetchData();
+ return {
+ componentHtml: Hello {data.name}
,
+ title: `${data.title}`,
+ metaTags: ``,
+ };
+};
+AsyncObjectComponent.renderFunction = true;
+ReactOnRails.register({ AsyncObjectComponent });
+```
+
+```erb
+<%# Ruby - MUST use react_component_hash %>
+<% helmet_data = react_component_hash("AsyncObjectComponent", props: { dataUrl: "/api/data" }) %>
+
+<% content_for :head do %>
+ <%= helmet_data["title"] %>
+ <%= helmet_data["metaTags"] %>
+<% end %>
+
+
+ <%= helmet_data["componentHtml"] %>
+
+```
+
+### Return Type 6: Promise of React Component
+
+```jsx
+const AsyncReactComponent = async (props) => {
+ const data = await fetchData();
+ return () => Hello {data.name}
;
+};
+AsyncReactComponent.renderFunction = true;
+ReactOnRails.register({ AsyncReactComponent });
+```
+
+```erb
+<%# Ruby %>
+<%= react_component("AsyncReactComponent", props: { dataUrl: "/api/data" }) %>
+```
+
+### Return Type 7: Redirect Object
+
+```jsx
+const RedirectComponent = (props, railsContext) => {
+ if (!railsContext.currentUser) {
+ return {
+ redirectLocation: { pathname: '/login', search: '' },
+ };
+ }
+ return {
+ renderedHtml: Welcome {railsContext.currentUser.name}
,
+ };
+};
+RedirectComponent.renderFunction = true;
+ReactOnRails.register({ RedirectComponent });
+```
+
+```erb
+<%# Ruby %>
+<%= react_component("RedirectComponent") %>
+```
+
+By understanding these return types and which helper to use with each, you can create sophisticated server-rendered React components that fully integrate with your Rails views.
diff --git a/eslint.config.ts b/eslint.config.ts
index a2f4bf83b6..8cfc1087a4 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -145,7 +145,7 @@ const config = tsEslint.config([
languageOptions: {
parserOptions: {
projectService: {
- allowDefaultProject: ['eslint.config.ts', 'knip.ts'],
+ allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'],
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
// for some imports.
defaultProject: 'tsconfig.eslint.json',
diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts
index 718a62de52..7abd689e62 100644
--- a/node_package/src/ReactOnRailsRSC.ts
+++ b/node_package/src/ReactOnRailsRSC.ts
@@ -1,8 +1,7 @@
import { renderToPipeableStream } from 'react-on-rails-rsc/server.node';
import { PassThrough, Readable } from 'stream';
-import type { ReactElement } from 'react';
-import { RSCRenderParams, StreamRenderState } from './types';
+import { RSCRenderParams, StreamRenderState, StreamableComponentResult } from './types';
import ReactOnRails from './ReactOnRails.full';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
@@ -21,7 +20,10 @@ const stringToStream = (str: string) => {
return stream;
};
-const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRenderParams): Readable => {
+const streamRenderRSCComponent = (
+ reactRenderingResult: StreamableComponentResult,
+ options: RSCRenderParams,
+): Readable => {
const { throwJsErrors, reactClientManifestFileName } = options;
const renderState: StreamRenderState = {
result: null,
@@ -31,8 +33,8 @@ const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRender
const { pipeToTransform, readableStream, emitError } =
transformRenderStreamChunksToResultObject(renderState);
- loadReactClientManifest(reactClientManifestFileName)
- .then((reactClientManifest) => {
+ Promise.all([loadReactClientManifest(reactClientManifestFileName), reactRenderingResult])
+ .then(([reactClientManifest, reactElement]) => {
const rscStream = renderToPipeableStream(reactElement, reactClientManifest, {
onError: (err) => {
const error = convertToError(err);
diff --git a/node_package/src/createReactOutput.ts b/node_package/src/createReactOutput.ts
index 13851d52f4..2a7c0b9cd8 100644
--- a/node_package/src/createReactOutput.ts
+++ b/node_package/src/createReactOutput.ts
@@ -1,13 +1,27 @@
import * as React from 'react';
-import type {
- ServerRenderResult,
- CreateParams,
- ReactComponent,
- RenderFunction,
- CreateReactOutputResult,
-} from './types/index';
+import type { CreateParams, ReactComponent, RenderFunction, CreateReactOutputResult } from './types/index';
import { isServerRenderHash, isPromise } from './isServerRenderResult';
+function createReactElementFromRenderFunctionResult(
+ renderFunctionResult: ReactComponent,
+ name: string,
+ props: Record | undefined,
+): React.ReactElement {
+ if (React.isValidElement(renderFunctionResult)) {
+ // If already a ReactElement, then just return it.
+ console.error(
+ `Warning: ReactOnRails: Your registered render-function (ReactOnRails.register) for ${name}
+incorrectly returned a React Element (JSX). Instead, return a React Function Component by
+wrapping your JSX in a function. ReactOnRails v13 will throw error on this, as React Hooks do not
+work if you return JSX. Update by wrapping the result JSX of ${name} in a fat arrow function.`,
+ );
+ return renderFunctionResult;
+ }
+
+ // If a component, then wrap in an element
+ return React.createElement(renderFunctionResult, props);
+}
+
/**
* Logic to either call the renderFunction or call React.createElement to get the
* React.Component
@@ -53,32 +67,25 @@ export default function createReactOutput({
console.log(`${name} is a renderFunction`);
}
const renderFunctionResult = (component as RenderFunction)(props, railsContext);
- if (isServerRenderHash(renderFunctionResult as CreateReactOutputResult)) {
+ if (isServerRenderHash(renderFunctionResult)) {
// We just return at this point, because calling function knows how to handle this case and
// we can't call React.createElement with this type of Object.
- return renderFunctionResult as ServerRenderResult;
+ return renderFunctionResult;
}
- if (isPromise(renderFunctionResult as CreateReactOutputResult)) {
+ if (isPromise(renderFunctionResult)) {
// We just return at this point, because calling function knows how to handle this case and
// we can't call React.createElement with this type of Object.
- return renderFunctionResult as Promise;
- }
-
- if (React.isValidElement(renderFunctionResult)) {
- // If already a ReactElement, then just return it.
- console.error(
- `Warning: ReactOnRails: Your registered render-function (ReactOnRails.register) for ${name}
-incorrectly returned a React Element (JSX). Instead, return a React Function Component by
-wrapping your JSX in a function. ReactOnRails v13 will throw error on this, as React Hooks do not
-work if you return JSX. Update by wrapping the result JSX of ${name} in a fat arrow function.`,
- );
- return renderFunctionResult;
+ return renderFunctionResult.then((result) => {
+ // If the result is a function, then it returned a React Component (even class components are functions).
+ if (typeof result === 'function') {
+ return createReactElementFromRenderFunctionResult(result, name, props);
+ }
+ return result;
+ });
}
- // If a component, then wrap in an element
- const reactComponent = renderFunctionResult as ReactComponent;
- return React.createElement(reactComponent, props);
+ return createReactElementFromRenderFunctionResult(renderFunctionResult, name, props);
}
// else
return React.createElement(component as ReactComponent, props);
diff --git a/node_package/src/handleError.ts b/node_package/src/handleError.ts
index b57d998cb6..87b63bb91f 100644
--- a/node_package/src/handleError.ts
+++ b/node_package/src/handleError.ts
@@ -60,7 +60,10 @@ Message: ${e.message}
${e.stack}`;
const reactElement = React.createElement('pre', null, msg);
- return ReactDOMServer.renderToString(reactElement);
+ if (typeof ReactDOMServer.renderToString === 'function') {
+ return ReactDOMServer.renderToString(reactElement);
+ }
+ return msg;
}
return 'undefined';
diff --git a/node_package/src/isServerRenderResult.ts b/node_package/src/isServerRenderResult.ts
index cf8772b1f1..299bf6376b 100644
--- a/node_package/src/isServerRenderResult.ts
+++ b/node_package/src/isServerRenderResult.ts
@@ -1,6 +1,13 @@
-import type { CreateReactOutputResult, ServerRenderResult } from './types/index';
+import type {
+ CreateReactOutputResult,
+ ServerRenderResult,
+ RenderFunctionResult,
+ RenderStateHtml,
+} from './types/index';
-export function isServerRenderHash(testValue: CreateReactOutputResult): testValue is ServerRenderResult {
+export function isServerRenderHash(
+ testValue: CreateReactOutputResult | RenderFunctionResult,
+): testValue is ServerRenderResult {
return !!(
(testValue as ServerRenderResult).renderedHtml ||
(testValue as ServerRenderResult).redirectLocation ||
@@ -10,7 +17,7 @@ export function isServerRenderHash(testValue: CreateReactOutputResult): testValu
}
export function isPromise(
- testValue: CreateReactOutputResult | Promise | string | null,
+ testValue: CreateReactOutputResult | RenderFunctionResult | Promise | RenderStateHtml | 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 f7a5e3bcc4..5c604b4a89 100644
--- a/node_package/src/serverRenderReactComponent.ts
+++ b/node_package/src/serverRenderReactComponent.ts
@@ -1,3 +1,4 @@
+import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import type { ReactElement } from 'react';
@@ -14,6 +15,8 @@ import type {
RenderState,
RenderOptions,
ServerRenderResult,
+ CreateReactOutputAsyncResult,
+ RenderStateHtml,
} from './types';
function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState {
@@ -24,7 +27,7 @@ function processServerRenderHash(result: ServerRenderResult, options: RenderOpti
console.error(`React Router ERROR: ${JSON.stringify(routeError)}`);
}
- let htmlResult: string;
+ let htmlResult;
if (redirectLocation) {
if (options.trace) {
const redirectPath = redirectLocation.pathname + redirectLocation.search;
@@ -36,25 +39,10 @@ function processServerRenderHash(result: ServerRenderResult, options: RenderOpti
// Possibly, someday, we could have the Rails server redirect.
htmlResult = '';
} else {
- htmlResult = result.renderedHtml as string;
+ htmlResult = result.renderedHtml;
}
- 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;
+ return { result: htmlResult ?? null, hasErrors };
}
function processReactElement(result: ReactElement): string {
@@ -68,6 +56,26 @@ as a renderFunction and not a simple React Function Component.`);
}
}
+function processPromise(
+ result: CreateReactOutputAsyncResult,
+ renderingReturnsPromises: boolean,
+): RenderStateHtml {
+ if (!renderingReturnsPromises) {
+ console.error(
+ 'Your render function returned a Promise, which is only supported by the React on Rails Pro 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.then((promiseResult) => {
+ if (React.isValidElement(promiseResult)) {
+ return processReactElement(promiseResult);
+ }
+ return promiseResult;
+ });
+}
+
function processRenderingResult(result: CreateReactOutputResult, options: RenderOptions): RenderState {
if (isServerRenderHash(result)) {
return processServerRenderHash(result, options);
@@ -91,7 +99,7 @@ function handleRenderingError(e: unknown, options: { componentName: string; thro
}
async function createPromiseResult(
- renderState: RenderState & { result: Promise },
+ renderState: RenderState,
componentName: string,
throwJsErrors: boolean,
): Promise {
diff --git a/node_package/src/serverRenderUtils.ts b/node_package/src/serverRenderUtils.ts
index c77926cec7..5cb7f4cf03 100644
--- a/node_package/src/serverRenderUtils.ts
+++ b/node_package/src/serverRenderUtils.ts
@@ -1,7 +1,13 @@
-import type { RegisteredComponent, RenderResult, RenderState, StreamRenderState } from './types';
+import type {
+ RegisteredComponent,
+ RenderResult,
+ RenderState,
+ StreamRenderState,
+ FinalHtmlResult,
+} from './types';
export function createResultObject(
- html: string | null,
+ html: FinalHtmlResult | null,
consoleReplayScript: string,
renderState: RenderState | StreamRenderState,
): RenderResult {
diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts
index 5346b6a6c1..baa7a63cfe 100644
--- a/node_package/src/streamServerRenderedReactComponent.ts
+++ b/node_package/src/streamServerRenderedReactComponent.ts
@@ -1,6 +1,6 @@
+import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import { PassThrough, Readable } from 'stream';
-import type { ReactElement } from 'react';
import ComponentRegistry from './ComponentRegistry';
import createReactOutput from './createReactOutput';
@@ -8,14 +8,7 @@ import { isPromise, isServerRenderHash } from './isServerRenderResult';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
-import type { RenderParams, StreamRenderState } from './types';
-
-const stringToStream = (str: string): Readable => {
- const stream = new PassThrough();
- stream.write(str);
- stream.end();
- return stream;
-};
+import type { RenderParams, StreamRenderState, StreamableComponentResult } from './types';
type BufferedEvent = {
event: 'data' | 'error' | 'end';
@@ -128,7 +121,10 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
};
-const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
+const streamRenderReactComponent = (
+ reactRenderingResult: StreamableComponentResult,
+ options: RenderParams,
+) => {
const { name: componentName, throwJsErrors, domNodeId } = options;
const renderState: StreamRenderState = {
result: null,
@@ -139,42 +135,59 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options:
const { readableStream, pipeToTransform, writeChunk, emitError, endStream } =
transformRenderStreamChunksToResultObject(renderState);
- const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
- onShellError(e) {
- const error = convertToError(e);
- renderState.hasErrors = true;
- renderState.error = error;
+ const reportError = (error: Error) => {
+ renderState.hasErrors = true;
+ renderState.error = error;
- if (throwJsErrors) {
- emitError(error);
- }
+ if (throwJsErrors) {
+ emitError(error);
+ }
+ };
- const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
- writeChunk(errorHtml);
- endStream();
- },
- onShellReady() {
- renderState.isShellReady = true;
- pipeToTransform(renderingStream);
- },
- onError(e) {
- if (!renderState.isShellReady) {
+ const sendErrorHtml = (error: Error) => {
+ const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
+ writeChunk(errorHtml);
+ endStream();
+ };
+
+ Promise.resolve(reactRenderingResult)
+ .then((reactRenderedElement) => {
+ if (typeof reactRenderedElement === 'string') {
+ console.error(
+ `Error: stream_react_component helper received a string instead of a React component for component "${componentName}".\n` +
+ 'To benefit from React on Rails Pro streaming feature, your render function should return a React component.\n' +
+ 'Do not call ReactDOMServer.renderToString() inside the render function as this defeats the purpose of streaming.\n',
+ );
+
+ writeChunk(reactRenderedElement);
+ endStream();
return;
}
+
+ const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderedElement, {
+ onShellError(e) {
+ sendErrorHtml(convertToError(e));
+ },
+ onShellReady() {
+ renderState.isShellReady = true;
+ pipeToTransform(renderingStream);
+ },
+ onError(e) {
+ reportError(convertToError(e));
+ },
+ identifierPrefix: domNodeId,
+ });
+ })
+ .catch((e: unknown) => {
const error = convertToError(e);
- if (throwJsErrors) {
- emitError(error);
- }
- renderState.hasErrors = true;
- renderState.error = error;
- },
- identifierPrefix: domNodeId,
- });
+ reportError(error);
+ sendErrorHtml(error);
+ });
return readableStream;
};
-type StreamRenderer = (reactElement: ReactElement, options: P) => T;
+type StreamRenderer = (reactElement: StreamableComponentResult, options: P) => T;
export const streamServerRenderedComponent = (
options: P,
@@ -194,22 +207,40 @@ export const streamServerRenderedComponent = (
railsContext,
});
- if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
- throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
+ if (isServerRenderHash(reactRenderingResult)) {
+ throw new Error('Server rendering of streams is not supported for server render hashes.');
+ }
+
+ if (isPromise(reactRenderingResult)) {
+ const promiseAfterRejectingHash = reactRenderingResult.then((result) => {
+ if (!React.isValidElement(result)) {
+ throw new Error(
+ `Invalid React element detected while rendering ${componentName}. If you are trying to stream a component registered as a render function, ` +
+ `please ensure that the render function returns a valid React component, not a server render hash. ` +
+ `This error typically occurs when the render function does not return a React element or returns an incorrect type.`,
+ );
+ }
+ return result;
+ });
+ return renderStrategy(promiseAfterRejectingHash, options);
}
return renderStrategy(reactRenderingResult, options);
} catch (e) {
+ const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject({
+ hasErrors: true,
+ isShellReady: false,
+ result: null,
+ });
if (throwJsErrors) {
- throw e;
+ emitError(e);
}
const error = convertToError(e);
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
- const jsonResult = JSON.stringify(
- createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }),
- );
- return stringToStream(jsonResult) as T;
+ writeChunk(htmlResult);
+ endStream();
+ return readableStream as T;
}
};
diff --git a/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts b/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts
index 094e786afb..03eba234f2 100644
--- a/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts
+++ b/node_package/src/transformRSCStreamAndReplayConsoleLogs.ts
@@ -1,4 +1,4 @@
-import { RenderResult } from './types';
+import { RSCPayloadChunk } from './types';
export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream) {
return new ReadableStream({
@@ -18,7 +18,7 @@ export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableS
.filter((line) => line.trim() !== '')
.map((line) => {
try {
- return JSON.parse(line) as RenderResult;
+ return JSON.parse(line) as RSCPayloadChunk;
} catch (error) {
console.error('Error parsing JSON:', line, error);
throw error;
diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts
index 4e19e82303..16a8a8897f 100644
--- a/node_package/src/types/index.ts
+++ b/node_package/src/types/index.ts
@@ -45,19 +45,34 @@ type AuthenticityHeaders = Record & {
type StoreGenerator = (props: Record, railsContext: RailsContext) => Store;
+type ServerRenderHashRenderedHtml = {
+ componentHtml: string;
+ [key: string]: string;
+};
+
interface ServerRenderResult {
- renderedHtml?: string | { componentHtml: string; [key: string]: string };
+ renderedHtml?: string | ServerRenderHashRenderedHtml;
redirectLocation?: { pathname: string; search: string };
routeError?: Error;
error?: Error;
}
-type CreateReactOutputResult = ServerRenderResult | ReactElement | Promise;
+type CreateReactOutputSyncResult = ServerRenderResult | ReactElement;
+
+type CreateReactOutputAsyncResult = Promise>;
+
+type CreateReactOutputResult = CreateReactOutputSyncResult | CreateReactOutputAsyncResult;
+
+type RenderFunctionSyncResult = ReactComponent | ServerRenderResult;
+
+type RenderFunctionAsyncResult = Promise;
-type RenderFunctionResult = ReactComponent | ServerRenderResult | Promise;
+type RenderFunctionResult = RenderFunctionSyncResult | RenderFunctionAsyncResult;
+
+type StreamableComponentResult = ReactElement | Promise;
/**
- * Render functions are used to create dynamic React components or server-rendered HTML with side effects.
+ * 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
@@ -99,6 +114,12 @@ export type {
StoreGenerator,
CreateReactOutputResult,
ServerRenderResult,
+ ServerRenderHashRenderedHtml,
+ CreateReactOutputSyncResult,
+ CreateReactOutputAsyncResult,
+ RenderFunctionSyncResult,
+ RenderFunctionAsyncResult,
+ StreamableComponentResult,
};
export interface RegisteredComponent {
@@ -112,7 +133,7 @@ export interface RegisteredComponent {
// 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.
+ // All renderer functions are render-functions, but not all render-functions are renderer functions.
isRenderer: boolean;
}
@@ -155,14 +176,20 @@ export interface ErrorOptions {
export type RenderingError = Pick;
+export type FinalHtmlResult = string | ServerRenderHashRenderedHtml;
+
export interface RenderResult {
- html: string | null;
+ html: FinalHtmlResult | null;
consoleReplayScript: string;
hasErrors: boolean;
renderingError?: RenderingError;
isShellReady?: boolean;
}
+export interface RSCPayloadChunk extends RenderResult {
+ html: string;
+}
+
// from react-dom 18
export interface Root {
render(children: ReactNode): void;
@@ -351,8 +378,10 @@ export interface ReactOnRailsInternal extends ReactOnRails {
options: ReactOnRailsOptions;
}
+export type RenderStateHtml = FinalHtmlResult | Promise;
+
export type RenderState = {
- result: null | string | Promise;
+ result: null | RenderStateHtml;
hasErrors: boolean;
error?: RenderingError;
};
diff --git a/node_package/tests/serverRenderReactComponent.test.ts b/node_package/tests/serverRenderReactComponent.test.ts
index 7a768580d2..dd72c34528 100644
--- a/node_package/tests/serverRenderReactComponent.test.ts
+++ b/node_package/tests/serverRenderReactComponent.test.ts
@@ -1,10 +1,27 @@
-/* eslint-disable react/jsx-filename-extension */
-/* eslint-disable no-unused-vars */
-
import * as React from 'react';
-
import serverRenderReactComponent from '../src/serverRenderReactComponent';
import ComponentRegistry from '../src/ComponentRegistry';
+import type {
+ RenderParams,
+ RenderResult,
+ RailsContext,
+ RenderFunction,
+ RenderFunctionResult,
+} from '../src/types';
+
+const assertIsString: (value: unknown) => asserts value is string = (value: unknown) => {
+ if (typeof value !== 'string') {
+ throw new Error(`Expected value to be of type 'string', but received type '${typeof value}'`);
+ }
+};
+
+const assertIsPromise: (value: null | string | Promise) => asserts value is Promise = (
+ value: null | string | Promise,
+) => {
+ if (!value || typeof (value as Promise).then !== 'function') {
+ throw new Error(`Expected value to be of type 'Promise', but received type '${typeof value}'`);
+ }
+};
describe('serverRenderReactComponent', () => {
beforeEach(() => {
@@ -12,78 +29,252 @@ describe('serverRenderReactComponent', () => {
});
it('serverRenderReactComponent renders a registered component', () => {
- expect.assertions(2);
- const X1 = () => HELLO
;
+ const X1: React.FC = () => React.createElement('div', null, 'HELLO');
ComponentRegistry.register({ X1 });
- const renderResult = serverRenderReactComponent({ name: 'X1', domNodeId: 'myDomId', trace: false });
- const { html, hasErrors } = JSON.parse(renderResult);
+ const renderParams: RenderParams = {
+ name: 'X1',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
- const result = html.indexOf('>HELLO') > 0;
+ assertIsString(html);
+ const result = html && html.indexOf('>HELLO') > 0;
expect(result).toBeTruthy();
expect(hasErrors).toBeFalsy();
});
it('serverRenderReactComponent renders errors', () => {
- expect.assertions(2);
- const X2 = () => {
+ const X2: React.FC = () => {
throw new Error('XYZ');
};
ComponentRegistry.register({ X2 });
- // Not testing the consoleReplayScript, as handleError is putting the console to the test
- // runner log.
- const renderResult = serverRenderReactComponent({ name: 'X2', domNodeId: 'myDomId', trace: false });
- const { html, hasErrors } = JSON.parse(renderResult);
+ const renderParams: RenderParams = {
+ name: 'X2',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
- const result = html.indexOf('XYZ') > 0 && html.indexOf('Exception in rendering!') > 0;
+ assertIsString(html);
+ const result = html && html.indexOf('XYZ') > 0 && html.indexOf('Exception in rendering!') > 0;
expect(result).toBeTruthy();
expect(hasErrors).toBeTruthy();
});
- it('serverRenderReactComponent renders html', () => {
- expect.assertions(2);
+ it('serverRenderReactComponent renders html renderedHtml property', () => {
const expectedHtml = 'Hello
';
- const X3 = (props, _railsContext) => ({ renderedHtml: expectedHtml });
+ const X3: RenderFunction = () => ({
+ renderedHtml: expectedHtml,
+ });
+ X3.renderFunction = true;
ComponentRegistry.register({ X3 });
- const renderResult = serverRenderReactComponent({ name: 'X3', domNodeId: 'myDomId', trace: false });
- const { html, hasErrors, renderedHtml } = JSON.parse(renderResult);
+ const renderParams: RenderParams = {
+ name: 'X3',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
expect(html).toEqual(expectedHtml);
expect(hasErrors).toBeFalsy();
});
- it('serverRenderReactComponent renders an error if attempting to render a renderer', () => {
- expect.assertions(1);
- const X4 = (a1, a2, a3) => null;
+ it("doesn't render object without renderedHtml property", () => {
+ const X4 = (): { foo: string } => ({
+ foo: 'bar',
+ });
+ X4.renderFunction = true;
+
+ ComponentRegistry.register({ X4: X4 as unknown as RenderFunction });
+ const renderResult = serverRenderReactComponent({
+ name: 'X4',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ });
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
+
+ expect(html).toContain('Exception in rendering!');
+ expect(html).toContain('Element type is invalid');
+ expect(hasErrors).toBeTruthy();
+ });
+
+ // If a render function returns a string, serverRenderReactComponent will interpret it as a React component type.
+ // For example, if the render function returns the string 'div', it will be treated as if React.createElement('div', props)
+ // was called. Thus, calling `serverRenderReactComponent({ name: 'X4', props: { foo: 'bar' } })` with a render function
+ // that returns 'div' will result in the HTML ``.
+ // This behavior is an unintended side effect of the implementation.
+ // If the render function returns a real HTML string, it will most likely throw an invalid tag React error.
+ // For example, calling `serverRenderReactComponent({ name: 'X4', props: { foo: 'bar' } })` with a render function
+ // that returns 'Hello
' will result in the error:
+ // "Error: Invalid tag name Hello
"
+ it("doesn't render html string returned directly from render function", () => {
+ const expectedHtml = 'Hello
';
+ const X4: RenderFunction = (): string => expectedHtml;
+ X4.renderFunction = true;
+
ComponentRegistry.register({ X4 });
- const renderResult = serverRenderReactComponent({ name: 'X4', domNodeId: 'myDomId', trace: false });
- const { html } = JSON.parse(renderResult);
+ const renderParams: RenderParams = {
+ name: 'X4',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ };
- const result = html.indexOf('renderer') > 0 && html.indexOf('Exception in rendering!') > 0;
- expect(result).toBeTruthy();
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
+
+ // Instead of expecting exact strings, check that it contains key parts of the error
+ expect(html).toContain('Exception in rendering!');
+ expect(html).toContain('Invalid tag');
+ expect(html).toContain('div>Hello</div');
+ expect(hasErrors).toBeTruthy();
});
- it('serverRenderReactComponent renders promises', async () => {
- expect.assertions(2);
+ it('serverRenderReactComponent renders promise of string html', async () => {
const expectedHtml = 'Hello
';
- const X5 = (props, _railsContext) => Promise.resolve(expectedHtml);
+ const X5: RenderFunction = (): Promise => Promise.resolve(expectedHtml);
+ X5.renderFunction = true;
ComponentRegistry.register({ X5 });
- const renderResult = await serverRenderReactComponent({
+ const renderParams: RenderParams = {
name: 'X5',
domNodeId: 'myDomId',
trace: false,
+ throwJsErrors: false,
renderingReturnsPromises: true,
- });
- const html = await renderResult.html;
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsPromise(renderResult);
+ const html = await renderResult.then((r) => r.html);
expect(html).toEqual(expectedHtml);
- expect(renderResult.hasErrors).toBeFalsy();
+ await expect(renderResult.then((r) => r.hasErrors)).resolves.toBeFalsy();
+ });
+
+ // When an async render function returns an object, serverRenderReactComponent will return the object as it is.
+ // It does not validate properties like renderedHtml or hasErrors; it simply returns the object.
+ // This behavior can cause issues with the ruby_on_rails gem.
+ // To avoid such issues, ensure that the returned object includes a `componentHtml` property and use the `react_component_hash` helper.
+ // This is demonstrated in the "can render async render function used with react_component_hash helper" test.
+ it('serverRenderReactComponent returns the object returned by the async render function', async () => {
+ const resultObject = { renderedHtml: 'Hello
' };
+ const X6 = (() => Promise.resolve(resultObject)) as RenderFunction;
+ X6.renderFunction = true;
+
+ ComponentRegistry.register({ X6 });
+
+ const renderParams: RenderParams = {
+ name: 'X6',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: true,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsPromise(renderResult);
+ const html = await renderResult.then((r) => r.html);
+ expect(html).toMatchObject(resultObject);
+ });
+
+ // Because the object returned by the async render function is returned as it is,
+ // we can make the async render function returns an object with `componentHtml` property.
+ // This is useful when we want to render a component using the `react_component_hash` helper.
+ it('can render async render function used with react_component_hash helper', async () => {
+ const reactComponentHashResult = { componentHtml: 'Hello
' };
+ const X7: RenderFunction = () => Promise.resolve(reactComponentHashResult);
+ X7.renderFunction = true;
+
+ ComponentRegistry.register({ X7 });
+
+ const renderParams: RenderParams = {
+ name: 'X7',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: true,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsPromise(renderResult);
+ const result = await renderResult;
+ expect(result.html).toMatchObject(reactComponentHashResult);
+ });
+
+ it('serverRenderReactComponent renders async render function that returns react component', async () => {
+ const X8: RenderFunction = () => Promise.resolve(() => React.createElement('div', null, 'Hello'));
+ X8.renderFunction = true;
+
+ ComponentRegistry.register({ X8 });
+
+ const renderResult = serverRenderReactComponent({
+ name: 'X8',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: true,
+ });
+ assertIsPromise(renderResult);
+ const result = await renderResult;
+ expect(result.html).toEqual('Hello
');
+ });
+
+ it('serverRenderReactComponent renders an error if attempting to render a renderer', () => {
+ const X4: RenderFunction = (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _props: unknown,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _railsContext?: RailsContext,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _domNodeId?: string,
+ ): RenderFunctionResult => ({ renderedHtml: '' });
+
+ ComponentRegistry.register({ X4 });
+
+ const renderParams: RenderParams = {
+ name: 'X4',
+ domNodeId: 'myDomId',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ };
+
+ const renderResult = serverRenderReactComponent(renderParams);
+ assertIsString(renderResult);
+ const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
+
+ assertIsString(html);
+ const result = html && html.indexOf('renderer') > 0 && html.indexOf('Exception in rendering!') > 0;
+ expect(result).toBeTruthy();
+ expect(hasErrors).toBeTruthy();
});
});
diff --git a/node_package/tests/streamServerRenderedReactComponent.test.jsx b/node_package/tests/streamServerRenderedReactComponent.test.jsx
index 674a21110d..e90628e770 100644
--- a/node_package/tests/streamServerRenderedReactComponent.test.jsx
+++ b/node_package/tests/streamServerRenderedReactComponent.test.jsx
@@ -60,8 +60,41 @@ describe('streamServerRenderedReactComponent', () => {
throwSyncError = false,
throwJsErrors = false,
throwAsyncError = false,
+ componentType = 'reactComponent',
} = {}) => {
- ComponentRegistry.register({ TestComponentForStreaming });
+ switch (componentType) {
+ case 'reactComponent':
+ ComponentRegistry.register({ TestComponentForStreaming });
+ break;
+ case 'renderFunction':
+ ComponentRegistry.register({
+ TestComponentForStreaming: (props, _railsContext) => () => ,
+ });
+ break;
+ case 'asyncRenderFunction':
+ ComponentRegistry.register({
+ TestComponentForStreaming: (props, _railsContext) => () =>
+ Promise.resolve(),
+ });
+ break;
+ case 'erroneousRenderFunction':
+ ComponentRegistry.register({
+ TestComponentForStreaming: (_props, _railsContext) => {
+ // The error happen inside the render function itself not inside the returned React component
+ throw new Error('Sync Error from render function');
+ },
+ });
+ break;
+ case 'erroneousAsyncRenderFunction':
+ ComponentRegistry.register({
+ TestComponentForStreaming: (_props, _railsContext) =>
+ // The error happen inside the render function itself not inside the returned React component
+ Promise.reject(new Error('Async Error from render function')),
+ });
+ break;
+ default:
+ throw new Error(`Unknown component type: ${componentType}`);
+ }
const renderResult = streamServerRenderedReactComponent({
name: 'TestComponentForStreaming',
domNodeId: 'myDomId',
@@ -82,7 +115,7 @@ describe('streamServerRenderedReactComponent', () => {
it('streamServerRenderedReactComponent streams the rendered component', async () => {
const { renderResult, chunks } = setupStreamTest();
await new Promise((resolve) => {
- renderResult.on('end', resolve);
+ renderResult.once('end', resolve);
});
expect(chunks).toHaveLength(2);
@@ -101,7 +134,7 @@ describe('streamServerRenderedReactComponent', () => {
const onError = jest.fn();
renderResult.on('error', onError);
await new Promise((resolve) => {
- renderResult.on('end', resolve);
+ renderResult.once('end', resolve);
});
expect(onError).toHaveBeenCalled();
@@ -117,7 +150,7 @@ describe('streamServerRenderedReactComponent', () => {
const onError = jest.fn();
renderResult.on('error', onError);
await new Promise((resolve) => {
- renderResult.on('end', resolve);
+ renderResult.once('end', resolve);
});
expect(onError).not.toHaveBeenCalled();
@@ -133,17 +166,17 @@ describe('streamServerRenderedReactComponent', () => {
const onError = jest.fn();
renderResult.on('error', onError);
await new Promise((resolve) => {
- renderResult.on('end', resolve);
+ renderResult.once('end', resolve);
});
expect(onError).toHaveBeenCalled();
- expect(chunks).toHaveLength(2);
+ expect(chunks.length).toBeGreaterThanOrEqual(2);
expect(chunks[0].html).toContain('Header In The Shell');
expect(chunks[0].consoleReplayScript).toBe('');
expect(chunks[0].hasErrors).toBe(false);
expect(chunks[0].isShellReady).toBe(true);
// Script that fallbacks the render to client side
- expect(chunks[1].html).toMatch(/