Skip to content

Commit 71bba14

Browse files
refactor serverRenderReactComponent
1 parent 5c962fc commit 71bba14

File tree

3 files changed

+151
-128
lines changed

3 files changed

+151
-128
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: 140 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -6,154 +6,168 @@ 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: null | 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+
name: string;
19+
domNodeId?: string;
20+
trace?: boolean;
21+
renderingReturnsPromises: boolean;
22+
};
23+
24+
function validateComponent(componentObj: RegisteredComponent, name: string) {
25+
if (componentObj.isRenderer) {
26+
throw new Error(`Detected a renderer while server rendering component '${name}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
27+
}
28+
}
1529

16-
let renderResult: null | string | Promise<string> = null;
17-
let hasErrors = false;
18-
let renderingError: null | RenderingError = null;
30+
function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): string {
31+
const { redirectLocation, routeError } = result;
32+
const hasErrors = !!routeError;
1933

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`);
34+
if (hasErrors) {
35+
console.error(`React Router ERROR: ${JSON.stringify(routeError)}`);
36+
}
37+
38+
if (redirectLocation) {
39+
if (options.trace) {
40+
const redirectPath = redirectLocation.pathname + redirectLocation.search;
41+
console.log(`ROUTER REDIRECT: ${options.name} to dom node with id: ${options.domNodeId}, redirect to ${redirectPath}`);
2642
}
43+
return '';
44+
}
2745

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-
};
46+
return result.renderedHtml as string;
47+
}
6148

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-
};
49+
function processPromise(result: Promise<unknown>, renderingReturnsPromises: boolean): Promise<string> | string {
50+
if (!renderingReturnsPromises) {
51+
console.error('Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.');
52+
// If the app is using server rendering with ExecJS, then the promise will not be awaited.
53+
// And when a promise is passed to JSON.stringify, it will be converted to '{}'.
54+
return '{}';
55+
}
56+
return result as Promise<string>;
57+
}
6858

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
59+
function processReactElement(result: ReactElement): string {
60+
try {
61+
return ReactDOMServer.renderToString(result);
62+
} catch (error) {
63+
console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already
7464
calls renderToString, that takes one parameter. You need to add an extra unused parameter to identify this function
7565
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-
}
66+
throw error;
67+
}
68+
}
9169

92-
hasErrors = true;
93-
renderResult = handleError({
94-
e,
95-
name,
96-
serverSide: true,
97-
});
98-
renderingError = e;
70+
function processRenderingResult(result: CreateReactOutputResult, options: RenderOptions): string | Promise<string> {
71+
if (isServerRenderHash(result)) {
72+
return processServerRenderHash(result, options);
9973
}
74+
if (isPromise(result)) {
75+
return processPromise(result, options.renderingReturnsPromises);
76+
}
77+
return processReactElement(result);
78+
}
10079

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-
};
80+
function handleRenderingError(e: Error, renderState: RenderState, options: { name: string, throwJsErrors: boolean }) {
81+
if (options.throwJsErrors) {
82+
throw e;
83+
}
84+
return {
85+
...renderState,
86+
hasErrors: true,
87+
result: handleError({ e, name: options.name, serverSide: true }),
88+
error: e,
10889
};
90+
}
10991

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;
92+
function createResultObject(html: string | null, consoleReplayScript: string, hasErrors: boolean, error: RenderingError | null): RenderResult {
93+
const result: RenderResult = { html, consoleReplayScript, hasErrors };
94+
if (error) {
95+
result.renderingError = {
96+
message: error.message,
97+
stack: error.stack,
14198
};
99+
}
100+
return result;
101+
}
102+
103+
function createSyncResult(renderState: RenderState & { result: string | null }, consoleReplayScript: string): RenderResult {
104+
return createResultObject(renderState.result, consoleReplayScript, renderState.hasErrors, renderState.error);
105+
}
142106

143-
return resolveRenderResult();
107+
function createPromiseResult(renderState: RenderState & { result: Promise<string> }, consoleReplayScript: string): Promise<RenderResult> {
108+
return (async () => {
109+
try {
110+
const html = await renderState.result;
111+
return createResultObject(html, consoleReplayScript, renderState.hasErrors, renderState.error);
112+
} catch (e: unknown) {
113+
const error = e instanceof Error ? e : new Error(String(e));
114+
const html = handleError({ e: error, name: 'Unknown', serverSide: true });
115+
return createResultObject(html, consoleReplayScript, true, error);
116+
}
117+
})();
118+
}
119+
120+
function createFinalResult(renderState: RenderState): null | string | Promise<RenderResult> {
121+
const consoleReplayScript = buildConsoleReplay();
122+
123+
const { result } = renderState;
124+
if (isPromise(result)) {
125+
return createPromiseResult({ ...renderState, result }, consoleReplayScript);
144126
}
145127

146-
const result: RenderResult = {
147-
html: renderResult as string,
148-
consoleReplayScript,
149-
hasErrors,
128+
return JSON.stringify(createSyncResult({ ...renderState, result }, consoleReplayScript));
129+
}
130+
131+
function serverRenderReactComponentInternal(options: RenderParams): null | string | Promise<RenderResult> {
132+
const { name, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options;
133+
134+
let renderState: RenderState = {
135+
result: null,
136+
hasErrors: false,
137+
error: null,
150138
};
151139

152-
if (renderingError) {
153-
addRenderingErrors(result, renderingError);
140+
try {
141+
const componentObj = ComponentRegistry.get(name);
142+
validateComponent(componentObj, name);
143+
144+
// Renders the component or executes the render function
145+
// - If the registered component is a React element or component, it renders it
146+
// - If it's a render function, it executes the function and processes the result:
147+
// - For React elements or components, it renders them
148+
// - For promises, it returns them without awaiting (for async rendering)
149+
// - For other values (e.g., strings), it returns them directly
150+
// Note: Only synchronous operations are performed at this stage
151+
const reactRenderingResult = createReactOutput({ componentObj, domNodeId, trace, props, railsContext });
152+
// Processes the result from createReactOutput:
153+
// 1. Converts React elements to HTML strings
154+
// 2. Returns rendered HTML from serverRenderHash
155+
// 3. Handles promises for async rendering
156+
renderState.result = processRenderingResult(reactRenderingResult, { name, domNodeId, trace, renderingReturnsPromises });
157+
} catch (e) {
158+
renderState = handleRenderingError(e as Error, renderState, { name, throwJsErrors });
154159
}
155160

156-
return JSON.stringify(result);
161+
// Finalize the rendering result and prepare it for server response
162+
// 1. Builds the consoleReplayScript for client-side console replay
163+
// 2. Handles both synchronous and asynchronous (Promise) results
164+
// 3. Constructs a JSON object with the following properties:
165+
// - html: string | null (The rendered component HTML)
166+
// - consoleReplayScript: string (Script to replay console outputs on the client)
167+
// - hasErrors: boolean (Indicates if any errors occurred during rendering)
168+
// - renderingError: Error | null (The error object if an error occurred, null otherwise)
169+
// 4. For Promise results, it awaits resolution before creating the final JSON
170+
return createFinalResult(renderState);
157171
}
158172

159173
const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (options) => {
@@ -165,4 +179,5 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
165179
console.history = [];
166180
}
167181
};
182+
168183
export default serverRenderReactComponent;

node_package/src/types/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,15 @@ export type { // eslint-disable-line import/prefer-default-export
6767
export interface RegisteredComponent {
6868
name: string;
6969
component: ReactComponentOrRenderFunction;
70+
// Indicates if the registered component is a Render-function.
71+
// Render-functions receive two arguments: props and railsContext.
72+
// They should return a string, React component, React element, or a Promise.
73+
// These functions are used to create dynamic React components or server-rendered HTML.
7074
renderFunction: boolean;
75+
// Indicates if the registered component is a Renderer function.
76+
// Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId)
77+
// Supported on the client side only.
78+
// All renderer functions are render functions, but not all render functions are renderer functions.
7179
isRenderer: boolean;
7280
}
7381

0 commit comments

Comments
 (0)