Skip to content

Commit ee382bc

Browse files
Refactor serverRenderReactComponent function (#1653)
* refactor serverRenderReactComponent * add stream package to the dummy app to fix the build problem * improve naming and organization * don't install stream package * add a comment about building console replay before awaiting promise * make a comment clearer * add a comment * move some comments to another place * make createResultObject takes renderState object
1 parent 5c962fc commit ee382bc

File tree

3 files changed

+191
-132
lines changed

3 files changed

+191
-132
lines changed

node_package/src/isServerRenderResult.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function isServerRenderHash(testValue: CreateReactOutputResult):
99
(testValue as ServerRenderResult).error);
1010
}
1111

12-
export function isPromise(testValue: CreateReactOutputResult):
13-
testValue is Promise<string> {
14-
return !!((testValue as Promise<string>).then);
12+
export function isPromise<T>(testValue: CreateReactOutputResult | Promise<T> | string | null):
13+
testValue is Promise<T> {
14+
return !!((testValue as Promise<T> | null)?.then);
1515
}

node_package/src/serverRenderReactComponent.ts

Lines changed: 156 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -6,154 +6,180 @@ import createReactOutput from './createReactOutput';
66
import { isPromise, isServerRenderHash } from './isServerRenderResult';
77
import buildConsoleReplay from './buildConsoleReplay';
88
import handleError from './handleError';
9-
import type { RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types';
9+
import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types';
1010

11-
/* eslint-disable @typescript-eslint/no-explicit-any */
11+
type RenderState = {
12+
result: null | string | Promise<string>;
13+
hasErrors: boolean;
14+
error?: RenderingError;
15+
};
1216

13-
function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise<RenderResult> {
14-
const { name, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options;
17+
type RenderOptions = {
18+
componentName: string;
19+
domNodeId?: string;
20+
trace?: boolean;
21+
renderingReturnsPromises: boolean;
22+
};
1523

16-
let renderResult: null | string | Promise<string> = null;
17-
let hasErrors = false;
18-
let renderingError: null | RenderingError = null;
24+
function validateComponent(componentObj: RegisteredComponent, componentName: string) {
25+
if (componentObj.isRenderer) {
26+
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
27+
}
28+
}
1929

20-
try {
21-
const componentObj = ComponentRegistry.get(name);
22-
if (componentObj.isRenderer) {
23-
throw new Error(`\
24-
Detected a renderer while server rendering component '${name}'. \
25-
See https://github.com/shakacode/react_on_rails#renderer-functions`);
30+
function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState {
31+
const { redirectLocation, routeError } = result;
32+
const hasErrors = !!routeError;
33+
34+
if (hasErrors) {
35+
console.error(`React Router ERROR: ${JSON.stringify(routeError)}`);
36+
}
37+
38+
let htmlResult: string;
39+
if (redirectLocation) {
40+
if (options.trace) {
41+
const redirectPath = redirectLocation.pathname + redirectLocation.search;
42+
console.log(`ROUTER REDIRECT: ${options.componentName} to dom node with id: ${options.domNodeId}, redirect to ${redirectPath}`);
2643
}
44+
// For redirects on server rendering, we can't stop Rails from returning the same result.
45+
// Possibly, someday, we could have the Rails server redirect.
46+
htmlResult = '';
47+
} else {
48+
htmlResult = result.renderedHtml as string;
49+
}
2750

28-
const reactRenderingResult = createReactOutput({
29-
componentObj,
30-
domNodeId,
31-
trace,
32-
props,
33-
railsContext,
34-
});
35-
36-
const processServerRenderHash = () => {
37-
// We let the client side handle any redirect
38-
// Set hasErrors in case we want to throw a Rails exception
39-
const { redirectLocation, routeError } = reactRenderingResult as ServerRenderResult;
40-
hasErrors = !!routeError;
41-
42-
if (hasErrors) {
43-
console.error(
44-
`React Router ERROR: ${JSON.stringify(routeError)}`,
45-
);
46-
}
47-
48-
if (redirectLocation) {
49-
if (trace) {
50-
const redirectPath = redirectLocation.pathname + redirectLocation.search;
51-
console.log(`\
52-
ROUTER REDIRECT: ${name} to dom node with id: ${domNodeId}, redirect to ${redirectPath}`,
53-
);
54-
}
55-
// For redirects on server rendering, we can't stop Rails from returning the same result.
56-
// Possibly, someday, we could have the rails server redirect.
57-
return '';
58-
}
59-
return (reactRenderingResult as ServerRenderResult).renderedHtml as string;
60-
};
61-
62-
const processPromise = () => {
63-
if (!renderingReturnsPromises) {
64-
console.error('Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.');
65-
}
66-
return reactRenderingResult;
67-
};
68-
69-
const processReactElement = () => {
70-
try {
71-
return ReactDOMServer.renderToString(reactRenderingResult as ReactElement);
72-
} catch (error) {
73-
console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already
51+
return { result: htmlResult, hasErrors };
52+
}
53+
54+
function processPromise(result: Promise<string>, renderingReturnsPromises: boolean): Promise<string> | string {
55+
if (!renderingReturnsPromises) {
56+
console.error('Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.');
57+
// If the app is using server rendering with ExecJS, then the promise will not be awaited.
58+
// And when a promise is passed to JSON.stringify, it will be converted to '{}'.
59+
return '{}';
60+
}
61+
return result;
62+
}
63+
64+
function processReactElement(result: ReactElement): string {
65+
try {
66+
return ReactDOMServer.renderToString(result);
67+
} catch (error) {
68+
console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already
7469
calls renderToString, that takes one parameter. You need to add an extra unused parameter to identify this function
7570
as a renderFunction and not a simple React Function Component.`);
76-
throw error;
77-
}
78-
};
79-
80-
if (isServerRenderHash(reactRenderingResult)) {
81-
renderResult = processServerRenderHash();
82-
} else if (isPromise(reactRenderingResult)) {
83-
renderResult = processPromise() as Promise<string>;
84-
} else {
85-
renderResult = processReactElement();
86-
}
87-
} catch (e: any) {
88-
if (throwJsErrors) {
89-
throw e;
90-
}
71+
throw error;
72+
}
73+
}
74+
75+
function processRenderingResult(result: CreateReactOutputResult, options: RenderOptions): RenderState {
76+
if (isServerRenderHash(result)) {
77+
return processServerRenderHash(result, options);
78+
}
79+
if (isPromise(result)) {
80+
return { result: processPromise(result, options.renderingReturnsPromises), hasErrors: false };
81+
}
82+
return { result: processReactElement(result), hasErrors: false };
83+
}
9184

92-
hasErrors = true;
93-
renderResult = handleError({
94-
e,
95-
name,
96-
serverSide: true,
97-
});
98-
renderingError = e;
85+
function handleRenderingError(e: unknown, options: { componentName: string, throwJsErrors: boolean }) {
86+
if (options.throwJsErrors) {
87+
throw e;
9988
}
89+
const error = e instanceof Error ? e : new Error(String(e));
90+
return {
91+
hasErrors: true,
92+
result: handleError({ e: error, name: options.componentName, serverSide: true }),
93+
error,
94+
};
95+
}
10096

101-
const consoleReplayScript = buildConsoleReplay();
102-
const addRenderingErrors = (resultObject: RenderResult, renderError: RenderingError) => {
103-
// Do not use `resultObject.renderingError = renderError` because JSON.stringify will turn it into '{}'.
104-
resultObject.renderingError = { // eslint-disable-line no-param-reassign
105-
message: renderError.message,
106-
stack: renderError.stack,
107-
};
97+
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState): RenderResult {
98+
return {
99+
html,
100+
consoleReplayScript,
101+
hasErrors: renderState.hasErrors,
102+
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
108103
};
104+
}
109105

110-
if (renderingReturnsPromises) {
111-
const resolveRenderResult = async () => {
112-
let promiseResult: RenderResult;
113-
114-
try {
115-
promiseResult = {
116-
html: await renderResult,
117-
consoleReplayScript,
118-
hasErrors,
119-
};
120-
} catch (e: any) {
121-
if (throwJsErrors) {
122-
throw e;
123-
}
124-
promiseResult = {
125-
html: handleError({
126-
e,
127-
name,
128-
serverSide: true,
129-
}),
130-
consoleReplayScript,
131-
hasErrors: true,
132-
};
133-
renderingError = e;
134-
}
135-
136-
if (renderingError !== null) {
137-
addRenderingErrors(promiseResult, renderingError);
138-
}
139-
140-
return promiseResult;
141-
};
142-
143-
return resolveRenderResult();
106+
async function createPromiseResult(
107+
renderState: RenderState & { result: Promise<string> },
108+
consoleReplayScript: string,
109+
componentName: string,
110+
throwJsErrors: boolean
111+
): Promise<RenderResult> {
112+
try {
113+
const html = await renderState.result;
114+
return createResultObject(html, consoleReplayScript, renderState);
115+
} catch (e: unknown) {
116+
const errorRenderState = handleRenderingError(e, { componentName, throwJsErrors });
117+
return createResultObject(errorRenderState.result, consoleReplayScript, errorRenderState);
144118
}
119+
}
145120

146-
const result: RenderResult = {
147-
html: renderResult as string,
148-
consoleReplayScript,
149-
hasErrors,
121+
function createFinalResult(
122+
renderState: RenderState,
123+
componentName: string,
124+
throwJsErrors: boolean
125+
): null | string | Promise<RenderResult> {
126+
// Console history is stored globally in `console.history`.
127+
// If node renderer is handling a render request that returns a promise,
128+
// It can handle another request while awaiting the promise.
129+
// To prevent cross-request console logs leakage between these requests,
130+
// we build the consoleReplayScript before awaiting any promises.
131+
// The console history is reset after the synchronous part of each request.
132+
// This causes console logs happening during async operations to not be captured.
133+
const consoleReplayScript = buildConsoleReplay();
134+
135+
const { result } = renderState;
136+
if (isPromise(result)) {
137+
return createPromiseResult({ ...renderState, result }, consoleReplayScript, componentName, throwJsErrors);
138+
}
139+
140+
return JSON.stringify(createResultObject(result, consoleReplayScript, renderState));
141+
}
142+
143+
function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise<RenderResult> {
144+
const { name: componentName, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options;
145+
146+
let renderState: RenderState = {
147+
result: null,
148+
hasErrors: false,
150149
};
151150

152-
if (renderingError) {
153-
addRenderingErrors(result, renderingError);
151+
try {
152+
const componentObj = ComponentRegistry.get(componentName);
153+
validateComponent(componentObj, componentName);
154+
155+
// Renders the component or executes the render function
156+
// - If the registered component is a React element or component, it renders it
157+
// - If it's a render function, it executes the function and processes the result:
158+
// - For React elements or components, it renders them
159+
// - For promises, it returns them without awaiting (for async rendering)
160+
// - For other values (e.g., strings), it returns them directly
161+
// Note: Only synchronous operations are performed at this stage
162+
const reactRenderingResult = createReactOutput({ componentObj, domNodeId, trace, props, railsContext });
163+
164+
// Processes the result from createReactOutput:
165+
// 1. Converts React elements to HTML strings
166+
// 2. Returns rendered HTML from serverRenderHash
167+
// 3. Handles promises for async rendering
168+
renderState = processRenderingResult(reactRenderingResult, { componentName, domNodeId, trace, renderingReturnsPromises });
169+
} catch (e: unknown) {
170+
renderState = handleRenderingError(e, { componentName, throwJsErrors });
154171
}
155172

156-
return JSON.stringify(result);
173+
// Finalize the rendering result and prepare it for server response
174+
// 1. Builds the consoleReplayScript for client-side console replay
175+
// 2. Extract the result from promise (if needed) by awaiting it
176+
// 3. Constructs a JSON object with the following properties:
177+
// - html: string | null (The rendered component HTML)
178+
// - consoleReplayScript: string (Script to replay console outputs on the client)
179+
// - hasErrors: boolean (Indicates if any errors occurred during rendering)
180+
// - renderingError: Error | null (The error object if an error occurred, null otherwise)
181+
// 4. For Promise results, it awaits resolution before creating the final JSON
182+
return createFinalResult(renderState, componentName, throwJsErrors);
157183
}
158184

159185
const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (options) => {
@@ -165,4 +191,5 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
165191
console.history = [];
166192
}
167193
};
194+
168195
export default serverRenderReactComponent;

node_package/src/types/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@ type CreateReactOutputResult = ServerRenderResult | ReactElement | Promise<strin
4343

4444
type RenderFunctionResult = ReactComponent | ServerRenderResult | Promise<string>;
4545

46+
/**
47+
* Render functions are used to create dynamic React components or server-rendered HTML with side effects.
48+
* They receive two arguments: props and railsContext.
49+
*
50+
* @param props - The component props passed to the render function
51+
* @param railsContext - The Rails context object containing environment information
52+
* @returns A string, React component, React element, or a Promise resolving to a string
53+
*
54+
* @remarks
55+
* To distinguish a render function from a React Function Component:
56+
* 1. Ensure it accepts two parameters (props and railsContext), even if railsContext is unused, or
57+
* 2. Set the `renderFunction` property to `true` on the function object.
58+
*
59+
* If neither condition is met, it will be treated as a React Function Component,
60+
* and ReactDOMServer will attempt to render it.
61+
*
62+
* @example
63+
* // Option 1: Two-parameter function
64+
* const renderFunction = (props, railsContext) => { ... };
65+
*
66+
* // Option 2: Using renderFunction property
67+
* const anotherRenderFunction = (props) => { ... };
68+
* anotherRenderFunction.renderFunction = true;
69+
*/
4670
interface RenderFunction {
4771
(props?: any, railsContext?: RailsContext, domNodeId?: string): RenderFunctionResult;
4872
// 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
6791
export interface RegisteredComponent {
6892
name: string;
6993
component: ReactComponentOrRenderFunction;
94+
/**
95+
* Indicates if the registered component is a RenderFunction
96+
* @see RenderFunction for more details on its behavior and usage.
97+
*/
7098
renderFunction: boolean;
99+
// Indicates if the registered component is a Renderer function.
100+
// Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId)
101+
// Supported on the client side only.
102+
// All renderer functions are render functions, but not all render functions are renderer functions.
71103
isRenderer: boolean;
72104
}
73105

0 commit comments

Comments
 (0)