Skip to content

Commit 5621e31

Browse files
Add Support for Async Render Functions Returning React Components (#1720)
* rename serverRenderReactComponent to TS * convert serverRenderReactComponent test to TS * Add usage guide for React on Rails render functions and tests for serverRenderReactComponent * update TS types to handle the case when render function return object * add support for returning react component from async render function * add support for streaming react components returned from async render function * on component streaming: report error at onError callback and return fallback html at onShellError callback * Enhance error handling and support for async render functions in streaming components and add more tests * keep html in final render result as object instead of stringifying it * Update ESLint configuration to allow default projects in test directories * fix linting in serverRenderReactComponent.test.ts * update docs * introduce StreamableComponentResult for improved type safety in streaming components * Update documentation for render functions to clarify property names and improve formatting * Improve formatting of documentation note regarding React component returns * Refactor ESLint configuration to simplify default project settings and enhance documentation on render functions, clarifying parameter usage and return types. * Update documentation to standardize terminology by changing "render functions" to "render-functions" * Empty commit to trigger new CI build * update changelog * Enhance documentation to clarify that promise options are exclusive to React on Rails Pro with the Node renderer
1 parent 73a9e4c commit 5621e31

14 files changed

+946
-154
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Changes since the last non-beta release.
2727

2828
- 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).
2929

30+
- 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).
31+
3032
### Removed (Breaking Changes)
3133

3234
- 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).

docs/javascript/render-functions.md

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# React on Rails Render-Functions: Usage Guide
2+
3+
This guide explains how render-functions work in React on Rails and how to use them with Ruby helper methods.
4+
5+
## Types of Render-Functions and Their Return Values
6+
7+
Render-functions take two parameters:
8+
9+
1. `props`: The props passed from the Ruby helper methods (via the `props:` parameter), which become available in your JavaScript.
10+
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.
11+
12+
### Identifying Render-Functions
13+
14+
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:
15+
16+
1. Accept two parameters in your function definition: `(props, railsContext)` - React on Rails will detect this signature (the parameter names don't matter).
17+
2. Add a `renderFunction = true` property to your function - This is useful when your function doesn't need the railsContext.
18+
19+
```jsx
20+
// Method 1: Use signature with two parameters
21+
const MyComponent = (props, railsContext) => {
22+
return () => (
23+
<div>
24+
Hello {props.name} from {railsContext.pathname}
25+
</div>
26+
);
27+
};
28+
29+
// Method 2: Use renderFunction property
30+
const MyOtherComponent = (props) => {
31+
return () => <div>Hello {props.name}</div>;
32+
};
33+
MyOtherComponent.renderFunction = true;
34+
35+
ReactOnRails.register({ MyComponent, MyOtherComponent });
36+
```
37+
38+
Render-functions can return several types of values:
39+
40+
### 1. React Components
41+
42+
```jsx
43+
const MyComponent = (props, _railsContext) => {
44+
// The `props` parameter here is identical to the `props` passed from the Ruby helper methods (via the `props:` parameter).
45+
// Both `props` and `reactProps` refer to the same object.
46+
return (reactProps) => <div>Hello {props.name}</div>;
47+
};
48+
```
49+
50+
> [!NOTE]
51+
> Ensure to return a React component (a function or class) and not a React element (the result of calling `React.createElement` or JSX).
52+
53+
### 2. Objects with `renderedHtml` string property
54+
55+
```jsx
56+
const MyComponent = (props, _railsContext) => {
57+
return {
58+
renderedHtml: `<div>Hello ${props.name}</div>`,
59+
};
60+
};
61+
```
62+
63+
### 3. Objects with `renderedHtml` as object containing `componentHtml` and other properties if needed (server-side hash)
64+
65+
```jsx
66+
const MyComponent = (props, _railsContext) => {
67+
return {
68+
renderedHtml: {
69+
componentHtml: <div>Hello {props.name}</div>,
70+
title: `<title>${props.title}</title>`,
71+
metaTags: `<meta name="description" content="${props.description}" />`,
72+
},
73+
};
74+
};
75+
```
76+
77+
### 4. Promises of Strings
78+
79+
This and other promise options below are only available in React on Rails Pro with the Node renderer.
80+
81+
```jsx
82+
const MyComponent = async (props, _railsContext) => {
83+
const data = await fetchData();
84+
return `<div>Hello ${data.name}</div>`;
85+
};
86+
```
87+
88+
### 5. Promises of server-side hash
89+
90+
```jsx
91+
const MyComponent = async (props, _railsContext) => {
92+
const data = await fetchData();
93+
return {
94+
componentHtml: `<div>Hello ${data.name}</div>`,
95+
title: `<title>${data.title}</title>`,
96+
metaTags: `<meta name="description" content="${data.description}" />`,
97+
};
98+
};
99+
```
100+
101+
### 6. Promises of React Components
102+
103+
```jsx
104+
const MyComponent = async (props, _railsContext) => {
105+
const data = await fetchData();
106+
return () => <div>Hello {data.name}</div>;
107+
};
108+
```
109+
110+
### 7. Redirect Information
111+
112+
> [!NOTE]
113+
> 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.
114+
115+
```jsx
116+
const MyComponent = (props, _railsContext) => {
117+
return {
118+
redirectLocation: { pathname: '/new-path', search: '' },
119+
routeError: null,
120+
};
121+
};
122+
```
123+
124+
## Important Rendering Behavior
125+
126+
Take a look at [serverRenderReactComponent.test.ts](https://github.com/shakacode/react_on_rails/blob/master/node_package/tests/serverRenderReactComponent.test.ts):
127+
128+
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: '...' }`.
129+
130+
2. **Objects Require Specific Properties** - Non-promise objects must include a `renderedHtml` property to be valid when used with `react_component`.
131+
132+
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).
133+
134+
## Ruby Helper Functions
135+
136+
### 1. react_component
137+
138+
The `react_component` helper renders a single React component in your view.
139+
140+
```ruby
141+
<%= react_component("MyComponent", props: { name: "John" }) %>
142+
```
143+
144+
This helper accepts render-functions that return React components, objects with a `renderedHtml` property, or promises that resolve to React components, or strings.
145+
146+
#### When to use:
147+
148+
- When you need to render a single component
149+
- When you're rendering client-side only
150+
- When your render function returns a single HTML string
151+
152+
#### Not suitable for:
153+
154+
- When your render function returns an object with multiple HTML strings
155+
- When you need to insert content in different parts of the page, such as meta tags & style tags
156+
157+
### 2. react_component_hash
158+
159+
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.
160+
161+
```ruby
162+
# With a render function that returns an object with multiple HTML properties
163+
<% helmet_data = react_component_hash("HelmetComponent", props: {
164+
title: "My Page",
165+
description: "Page description"
166+
}) %>
167+
168+
<% content_for :head do %>
169+
<%= helmet_data["title"] %>
170+
<%= helmet_data["metaTags"] %>
171+
<% end %>
172+
173+
<div class="main-content">
174+
<%= helmet_data["componentHtml"] %>
175+
</div>
176+
```
177+
178+
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.
179+
180+
#### When to use:
181+
182+
- When your render function returns multiple HTML strings in an object
183+
- When you need to insert rendered content in different parts of your page
184+
- For SEO-related rendering like meta tags and title tags
185+
- When working with libraries like React Helmet
186+
187+
#### Not suitable for:
188+
189+
- Simple component rendering
190+
- Client-side only rendering (always uses server rendering)
191+
192+
#### Requirements:
193+
194+
- The render function MUST return an object
195+
- The object MUST include a `componentHtml` key
196+
- All other keys are optional and can be accessed in your Rails view
197+
198+
## Examples with Appropriate Helper Methods
199+
200+
### Return Type 1: React Component
201+
202+
```jsx
203+
const SimpleComponent = (props, _railsContext) => () => <div>Hello {props.name}</div>;
204+
ReactOnRails.register({ SimpleComponent });
205+
```
206+
207+
```erb
208+
<%# Ruby %>
209+
<%= react_component("SimpleComponent", props: { name: "John" }) %>
210+
```
211+
212+
### Return Type 2: Object with renderedHtml
213+
214+
```jsx
215+
const RenderedHtmlComponent = (props, _railsContext) => {
216+
return { renderedHtml: `<div>Hello ${props.name}</div>` };
217+
};
218+
ReactOnRails.register({ RenderedHtmlComponent });
219+
```
220+
221+
```erb
222+
<%# Ruby %>
223+
<%= react_component("RenderedHtmlComponent", props: { name: "John" }) %>
224+
```
225+
226+
### Return Type 3: Object with server-side hash
227+
228+
```jsx
229+
const HelmetComponent = (props) => {
230+
return {
231+
renderedHtml: {
232+
componentHtml: <div>Hello {props.name}</div>,
233+
title: `<title>${props.title}</title>`,
234+
metaTags: `<meta name="description" content="${props.description}" />`,
235+
},
236+
};
237+
};
238+
// The render function should either:
239+
// 1. Accept two arguments: (props, railsContext)
240+
// 2. Have a property `renderFunction` set to true
241+
HelmetComponent.renderFunction = true;
242+
ReactOnRails.register({ HelmetComponent });
243+
```
244+
245+
```erb
246+
<%# Ruby - MUST use react_component_hash %>
247+
<% helmet_data = react_component_hash("HelmetComponent",
248+
props: { name: "John", title: "My Page", description: "Page description" }) %>
249+
250+
<% content_for :head do %>
251+
<%= helmet_data["title"] %>
252+
<%= helmet_data["metaTags"] %>
253+
<% end %>
254+
255+
<div class="content">
256+
<%= helmet_data["componentHtml"] %>
257+
</div>
258+
```
259+
260+
### Return Type 4: Promise of String
261+
262+
```jsx
263+
const AsyncStringComponent = async (props) => {
264+
const data = await fetchData();
265+
return `<div>Hello ${data.name}</div>`;
266+
};
267+
AsyncStringComponent.renderFunction = true;
268+
ReactOnRails.register({ AsyncStringComponent });
269+
```
270+
271+
```erb
272+
<%# Ruby %>
273+
<%= react_component("AsyncStringComponent", props: { dataUrl: "/api/data" }) %>
274+
```
275+
276+
### Return Type 5: Promise of server-side hash
277+
278+
```jsx
279+
const AsyncObjectComponent = async (props) => {
280+
const data = await fetchData();
281+
return {
282+
componentHtml: <div>Hello {data.name}</div>,
283+
title: `<title>${data.title}</title>`,
284+
metaTags: `<meta name="description" content="${data.description}" />`,
285+
};
286+
};
287+
AsyncObjectComponent.renderFunction = true;
288+
ReactOnRails.register({ AsyncObjectComponent });
289+
```
290+
291+
```erb
292+
<%# Ruby - MUST use react_component_hash %>
293+
<% helmet_data = react_component_hash("AsyncObjectComponent", props: { dataUrl: "/api/data" }) %>
294+
295+
<% content_for :head do %>
296+
<%= helmet_data["title"] %>
297+
<%= helmet_data["metaTags"] %>
298+
<% end %>
299+
300+
<div class="content">
301+
<%= helmet_data["componentHtml"] %>
302+
</div>
303+
```
304+
305+
### Return Type 6: Promise of React Component
306+
307+
```jsx
308+
const AsyncReactComponent = async (props) => {
309+
const data = await fetchData();
310+
return () => <div>Hello {data.name}</div>;
311+
};
312+
AsyncReactComponent.renderFunction = true;
313+
ReactOnRails.register({ AsyncReactComponent });
314+
```
315+
316+
```erb
317+
<%# Ruby %>
318+
<%= react_component("AsyncReactComponent", props: { dataUrl: "/api/data" }) %>
319+
```
320+
321+
### Return Type 7: Redirect Object
322+
323+
```jsx
324+
const RedirectComponent = (props, railsContext) => {
325+
if (!railsContext.currentUser) {
326+
return {
327+
redirectLocation: { pathname: '/login', search: '' },
328+
};
329+
}
330+
return {
331+
renderedHtml: <div>Welcome {railsContext.currentUser.name}</div>,
332+
};
333+
};
334+
RedirectComponent.renderFunction = true;
335+
ReactOnRails.register({ RedirectComponent });
336+
```
337+
338+
```erb
339+
<%# Ruby %>
340+
<%= react_component("RedirectComponent") %>
341+
```
342+
343+
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.

eslint.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const config = tsEslint.config([
145145
languageOptions: {
146146
parserOptions: {
147147
projectService: {
148-
allowDefaultProject: ['eslint.config.ts', 'knip.ts'],
148+
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'],
149149
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
150150
// for some imports.
151151
defaultProject: 'tsconfig.eslint.json',

node_package/src/ReactOnRailsRSC.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { renderToPipeableStream } from 'react-on-rails-rsc/server.node';
22
import { PassThrough, Readable } from 'stream';
3-
import type { ReactElement } from 'react';
43

5-
import { RSCRenderParams, StreamRenderState } from './types';
4+
import { RSCRenderParams, StreamRenderState, StreamableComponentResult } from './types';
65
import ReactOnRails from './ReactOnRails.full';
76
import buildConsoleReplay from './buildConsoleReplay';
87
import handleError from './handleError';
@@ -21,7 +20,10 @@ const stringToStream = (str: string) => {
2120
return stream;
2221
};
2322

24-
const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRenderParams): Readable => {
23+
const streamRenderRSCComponent = (
24+
reactRenderingResult: StreamableComponentResult,
25+
options: RSCRenderParams,
26+
): Readable => {
2527
const { throwJsErrors, reactClientManifestFileName } = options;
2628
const renderState: StreamRenderState = {
2729
result: null,
@@ -31,8 +33,8 @@ const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRender
3133

3234
const { pipeToTransform, readableStream, emitError } =
3335
transformRenderStreamChunksToResultObject(renderState);
34-
loadReactClientManifest(reactClientManifestFileName)
35-
.then((reactClientManifest) => {
36+
Promise.all([loadReactClientManifest(reactClientManifestFileName), reactRenderingResult])
37+
.then(([reactClientManifest, reactElement]) => {
3638
const rscStream = renderToPipeableStream(reactElement, reactClientManifest, {
3739
onError: (err) => {
3840
const error = convertToError(err);

0 commit comments

Comments
 (0)