Skip to content

Commit f3c16a6

Browse files
Refactor ReactOnRails to support React Server Components (RSC) registration and rendering
1 parent 429a5c4 commit f3c16a6

File tree

11 files changed

+292
-144
lines changed

11 files changed

+292
-144
lines changed

node_package/src/RSCServerRoot.ts

Lines changed: 90 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,90 @@
1-
// import * as React from 'react';
2-
// import { createFromNodeStream } from 'react-on-rails-rsc/client.node';
3-
// import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs';
4-
// import loadJsonFile from './loadJsonFile';
5-
6-
// if (!('use' in React && typeof React.use === 'function')) {
7-
// throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.');
8-
// }
9-
10-
// const { use } = React;
11-
12-
// export type RSCServerRootProps = {
13-
// getRscPromise: NodeJS.ReadableStream,
14-
// reactClientManifestFileName: string,
15-
// reactServerManifestFileName: string,
16-
// }
17-
18-
// const createFromFetch = (stream: NodeJS.ReadableStream, ssrManifest: Record<string, unknown>) => {
19-
// const transformedStream = transformRSCStream(stream);
20-
// return createFromNodeStream(transformedStream, ssrManifest);
21-
// }
22-
23-
// const createSSRManifest = (reactServerManifestFileName: string, reactClientManifestFileName: string) => {
24-
// const reactServerManifest = loadJsonFile(reactServerManifestFileName);
25-
// const reactClientManifest = loadJsonFile(reactClientManifestFileName);
26-
27-
// const ssrManifest = {
28-
// moduleLoading: {
29-
// prefix: "/webpack/development/",
30-
// crossOrigin: null,
31-
// },
32-
// moduleMap: {} as Record<string, unknown>,
33-
// };
34-
35-
// Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => {
36-
// const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl];
37-
// ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = {
38-
// '*': {
39-
// id: (serverFileBundlingInfo as { id: string }).id,
40-
// chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks,
41-
// name: '*',
42-
// }
43-
// };
44-
// });
45-
46-
// return ssrManifest;
47-
// }
48-
49-
// const RSCServerRoot = ({
50-
// getRscPromise,
51-
// reactClientManifestFileName,
52-
// reactServerManifestFileName,
53-
// }: RSCServerRootProps) => {
54-
// const ssrManifest = createSSRManifest(reactServerManifestFileName, reactClientManifestFileName);
55-
// return use(createFromFetch(getRscPromise, ssrManifest));
56-
// };
57-
58-
// export default RSCServerRoot;
1+
import * as React from 'react';
2+
import { createFromNodeStream } from 'react-on-rails-rsc/client.node';
3+
import type { RenderFunction, RailsContext } from './types';
4+
import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs';
5+
import loadJsonFile from './loadJsonFile';
6+
7+
declare global {
8+
function generateRSCPayload(
9+
componentName: string,
10+
props: Record<string, unknown>,
11+
serverSideRSCPayloadParameters: unknown,
12+
): Promise<NodeJS.ReadableStream>;
13+
}
14+
15+
type RSCServerRootProps = {
16+
componentName: string;
17+
componentProps: Record<string, unknown>;
18+
}
19+
20+
if (!('use' in React && typeof React.use === 'function')) {
21+
throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.');
22+
}
23+
24+
const { use } = React;
25+
26+
const createFromReactOnRailsNodeStream = (stream: NodeJS.ReadableStream, ssrManifest: Record<string, unknown>) => {
27+
const transformedStream = transformRSCStream(stream);
28+
return createFromNodeStream(transformedStream, ssrManifest);
29+
}
30+
31+
const createSSRManifest = async (reactServerManifestFileName: string, reactClientManifestFileName: string) => {
32+
const [reactServerManifest, reactClientManifest] = await Promise.all([
33+
loadJsonFile(reactServerManifestFileName),
34+
loadJsonFile(reactClientManifestFileName),
35+
]);
36+
37+
const ssrManifest = {
38+
moduleLoading: {
39+
prefix: "/webpack/development/",
40+
crossOrigin: null,
41+
},
42+
moduleMap: {} as Record<string, unknown>,
43+
};
44+
45+
Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => {
46+
const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl];
47+
ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = {
48+
'*': {
49+
id: (serverFileBundlingInfo as { id: string }).id,
50+
chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks,
51+
name: '*',
52+
}
53+
};
54+
});
55+
56+
return ssrManifest;
57+
}
58+
59+
const RSCServerRoot: RenderFunction = async ({ componentName, componentProps }: RSCServerRootProps, railsContext?: RailsContext) => {
60+
if (!railsContext?.serverSide || !railsContext?.reactClientManifestFileName || !railsContext?.reactServerClientManifestFileName) {
61+
throw new Error(
62+
`${'serverClientManifestFileName and reactServerClientManifestFileName are required. ' +
63+
'Please ensure that React Server Component webpack configurations are properly set ' +
64+
'as stated in the React Server Component tutorial. The received rails context is: '}${ JSON.stringify(railsContext)}`
65+
);
66+
}
67+
68+
if (typeof generateRSCPayload !== 'function') {
69+
throw new Error(
70+
'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' +
71+
'React on Rails Pro and the node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' +
72+
'is set to true.'
73+
);
74+
}
75+
76+
const ssrManifest = await createSSRManifest(
77+
railsContext.reactServerClientManifestFileName,
78+
railsContext.reactClientManifestFileName
79+
);
80+
const rscPayloadStream = await generateRSCPayload(
81+
componentName,
82+
componentProps,
83+
railsContext.serverSideRSCPayloadParameters
84+
);
85+
const serverComponentElement = createFromReactOnRailsNodeStream(rscPayloadStream, ssrManifest);
86+
87+
return () => use(serverComponentElement);
88+
};
89+
90+
export default RSCServerRoot;

node_package/src/ReactOnRailsRSC.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ const stringToStream = (str: string) => {
2222
};
2323

2424
const streamRenderRSCComponent = (reactRenderingResult: ReactElement | Promise<ReactElement | string>, options: RSCRenderParams): Readable => {
25-
const { throwJsErrors, reactClientManifestFileName } = options;
25+
const { throwJsErrors } = options;
26+
if (!options.railsContext?.serverSide || !options.railsContext.reactClientManifestFileName) {
27+
throw new Error('Rails context is not available');
28+
}
29+
30+
const { reactClientManifestFileName } = options.railsContext;
2631
const renderState: StreamRenderState = {
2732
result: null,
2833
hasErrors: false,

node_package/src/handleError.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ Message: ${e.message}
6060
${e.stack}`;
6161

6262
const reactElement = React.createElement('pre', null, msg);
63-
return ReactDOMServer.renderToString(reactElement);
63+
if (typeof ReactDOMServer.renderToString === 'function') {
64+
return ReactDOMServer.renderToString(reactElement);
65+
}
66+
return msg;
6467
}
6568

6669
return 'undefined';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import ReactOnRails from '../ReactOnRails.client';
2+
import { ReactComponent, RenderFunction } from '../types';
3+
4+
/**
5+
* Registers React Server Components (RSC) with React on Rails for the RSC bundle.
6+
*
7+
* This function handles the registration of components in the RSC bundle context,
8+
* where components are registered directly into the ComponentRegistry without any
9+
* additional wrapping. This is different from the server bundle registration,
10+
* which wraps components with RSCServerRoot.
11+
*
12+
* @param components - Object mapping component names to their implementations
13+
*
14+
* @example
15+
* ```js
16+
* registerServerComponent({
17+
* ServerComponent1: ServerComponent1Component,
18+
* ServerComponent2: ServerComponent2Component
19+
* });
20+
* ```
21+
*/
22+
const registerServerComponent = (
23+
components: { [id: string]: ReactComponent | RenderFunction },
24+
) => ReactOnRails.register(components);
25+
26+
export default registerServerComponent;
Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
import ReactOnRails from '../ReactOnRails.client';
2-
import { ReactComponent } from '../types';
2+
import RSCServerRoot from '../RSCServerRoot';
3+
import { ReactComponent, RenderFunction, RailsContext } from '../types';
34

45
/**
5-
* Registers React Server Components (RSC) with React on Rails for both server and RSC bundles.
6-
* Currently, this function behaves identically to ReactOnRails.register, but is introduced to enable
7-
* future RSC-specific functionality without breaking changes.
8-
*
9-
* Future behavior will differ based on bundle type:
10-
*
11-
* RSC Bundle:
12-
* - Components are registered as any other component by adding the component to the ComponentRegistry
13-
*
14-
* Server Bundle:
15-
* - It works like the function defined at `registerServerComponent/client`
16-
* - The function itself is not added to the ComponentRegistry
17-
* - Instead, a RSCServerRoot component is added to the ComponentRegistry
18-
* - This RSCServerRoot component will use the pre-generated RSC payloads from the RSC bundle to
19-
* build the rendering tree of the server component instead of rendering it again
20-
*
21-
* This functionality is added now without real implementation to avoid breaking changes in the future.
6+
* Registers React Server Components (RSC) with React on Rails for the server bundle.
7+
*
8+
* This function wraps each component with RSCServerRoot, which handles the server-side
9+
* rendering of React Server Components using pre-generated RSC payloads.
10+
*
11+
* The RSCServerRoot component:
12+
* - Uses pre-generated RSC payloads from the RSC bundle
13+
* - Builds the rendering tree of the server component
14+
* - Handles the integration with React's streaming SSR
2215
*
2316
* @param components - Object mapping component names to their implementations
2417
*
@@ -31,7 +24,14 @@ import { ReactComponent } from '../types';
3124
* ```
3225
*/
3326
const registerServerComponent = (components: Record<string, ReactComponent>) => {
34-
ReactOnRails.register(components);
27+
const componentsWrappedInRSCServerRoot: Record<string, RenderFunction> = {};
28+
for (const [componentName] of Object.entries(components)) {
29+
componentsWrappedInRSCServerRoot[componentName] = (
30+
componentProps?: unknown,
31+
railsContext?: RailsContext,
32+
) => RSCServerRoot({ componentName, componentProps }, railsContext);
33+
}
34+
return ReactOnRails.register(componentsWrappedInRSCServerRoot);
3535
};
3636

3737
export default registerServerComponent;

node_package/src/serverRenderReactComponent.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ function processPromise(
6363
// And when a promise is passed to JSON.stringify, it will be converted to '{}'.
6464
return '{}';
6565
}
66-
return result.then((result) => {
67-
if (typeof result !== 'string') {
68-
return processReactElement(result);
66+
return result.then((renderedResult) => {
67+
if (typeof renderedResult !== 'string') {
68+
return processReactElement(renderedResult);
6969
}
70-
return result;
70+
return renderedResult;
7171
});
7272
}
7373

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 26 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,13 @@ import { isServerRenderHash } from './isServerRenderResult';
88
import buildConsoleReplay from './buildConsoleReplay';
99
import handleError from './handleError';
1010
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
11-
import loadJsonFile from './loadJsonFile';
1211
import type { RenderParams, StreamRenderState } from './types';
1312

14-
const stringToStream = (str: string): Readable => {
15-
const stream = new PassThrough();
16-
stream.write(str);
17-
stream.end();
18-
return stream;
19-
};
20-
2113
type BufferedEvent = {
2214
event: 'data' | 'error' | 'end';
2315
data: unknown;
2416
};
2517

26-
const createSSRManifest = async (
27-
reactServerManifestFileName: string,
28-
reactClientManifestFileName: string,
29-
) => {
30-
const reactServerManifest = await loadJsonFile(reactServerManifestFileName);
31-
const reactClientManifest = await loadJsonFile(reactClientManifestFileName);
32-
33-
const ssrManifest = {
34-
moduleLoading: {
35-
prefix: '/webpack/development/',
36-
crossOrigin: null,
37-
},
38-
moduleMap: {} as Record<string, unknown>,
39-
};
40-
41-
Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => {
42-
const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl];
43-
ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = {
44-
'*': {
45-
id: (serverFileBundlingInfo as { id: string }).id,
46-
chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks,
47-
name: '*',
48-
},
49-
};
50-
});
51-
52-
return ssrManifest;
53-
};
54-
5518
/**
5619
* Creates a new Readable stream that safely buffers all events from the input stream until reading begins.
5720
*
@@ -169,6 +132,20 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement | Promise
169132
const { readableStream, pipeToTransform, writeChunk, emitError, endStream } =
170133
transformRenderStreamChunksToResultObject(renderState);
171134

135+
const onShellError = (e: unknown) => {
136+
const error = convertToError(e);
137+
renderState.hasErrors = true;
138+
renderState.error = error;
139+
140+
if (throwJsErrors) {
141+
emitError(error);
142+
}
143+
144+
const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
145+
writeChunk(errorHtml);
146+
endStream();
147+
}
148+
172149
Promise.resolve(reactRenderingResult).then(reactRenderedElement => {
173150
if (typeof reactRenderedElement === 'string') {
174151
console.error(
@@ -183,19 +160,7 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement | Promise
183160
}
184161

185162
const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderedElement, {
186-
onShellError(e) {
187-
const error = convertToError(e);
188-
renderState.hasErrors = true;
189-
renderState.error = error;
190-
191-
if (throwJsErrors) {
192-
emitError(error);
193-
}
194-
195-
const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
196-
writeChunk(errorHtml);
197-
endStream();
198-
},
163+
onShellError,
199164
onShellReady() {
200165
renderState.isShellReady = true;
201166
pipeToTransform(renderingStream);
@@ -213,7 +178,7 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement | Promise
213178
},
214179
identifierPrefix: domNodeId,
215180
});
216-
});
181+
}).catch(onShellError);
217182

218183
return readableStream;
219184
};
@@ -247,16 +212,21 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
247212

248213
return renderStrategy(reactRenderingResult, options);
249214
} catch (e) {
215+
const {
216+
readableStream,
217+
writeChunk,
218+
emitError,
219+
endStream
220+
} = transformRenderStreamChunksToResultObject({ hasErrors: true, isShellReady: false, result: null });
250221
if (throwJsErrors) {
251-
throw e;
222+
emitError(e);
252223
}
253224

254225
const error = convertToError(e);
255226
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
256-
const jsonResult = JSON.stringify(
257-
createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }),
258-
);
259-
return stringToStream(jsonResult) as T;
227+
writeChunk(htmlResult);
228+
endStream();
229+
return readableStream as T;
260230
}
261231
};
262232

0 commit comments

Comments
 (0)