Skip to content

Commit 26e11ba

Browse files
stream rsc payload in json objects like streamed react components
1 parent f2242c4 commit 26e11ba

File tree

9 files changed

+149
-79
lines changed

9 files changed

+149
-79
lines changed

lib/react_on_rails/helper.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ def internal_rsc_react_component(react_component_name, options = {})
436436
render_options = create_render_options(react_component_name, options)
437437
json_stream = server_rendered_react_component(render_options)
438438
json_stream.transform do |chunk|
439-
chunk[:html].html_safe
439+
(chunk.to_json + "\n").html_safe
440440
end
441441
end
442442

@@ -691,10 +691,7 @@ def server_rendered_react_component(render_options)
691691
js_code: js_code)
692692
end
693693

694-
# TODO: handle errors for rsc streams
695-
return result if render_options.rsc?
696-
697-
if render_options.stream?
694+
if render_options.stream? || render_options.rsc?
698695
result.transform do |chunk_json_result|
699696
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
700697
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)

lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,6 @@ def parse_result_and_replay_console_messages(result_string, render_options)
231231
begin
232232
result = JSON.parse(result_string)
233233
rescue JSON::ParserError => e
234-
return { html: result_string }
235234
raise ReactOnRails::JsonParseError.new(parse_error: e, json: result_string)
236235
end
237236

node_package/src/RSCClientRoot.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import RSDWClient from 'react-server-dom-webpack/client';
3+
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';
34

45
if (!('use' in React)) {
56
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.');
@@ -9,9 +10,19 @@ const { use } = React;
910

1011
const renderCache: Record<string, Promise<React.ReactNode>> = {};
1112

13+
const createFromFetch = async (fetchPromise: Promise<Response>) => {
14+
const response = await fetchPromise;
15+
const stream = response.body;
16+
if (!stream) {
17+
throw new Error('No stream found in response');
18+
}
19+
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
20+
return RSDWClient.createFromReadableStream(transformedStream);
21+
}
22+
1223
const fetchRSC = ({ componentName }: { componentName: string }) => {
1324
if (!renderCache[componentName]) {
14-
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)) as Promise<React.ReactNode>;
25+
renderCache[componentName] = createFromFetch(fetch(`/rsc/${componentName}`)) as Promise<React.ReactNode>;
1526
}
1627
return renderCache[componentName];
1728
}

node_package/src/ReactOnRails.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactElement } from 'react';
2-
import type { Readable, PassThrough } from 'stream';
2+
import type { Readable } from 'stream';
33

44
import * as ClientStartup from './clientStartup';
55
import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer';
@@ -302,7 +302,7 @@ ctx.ReactOnRails = {
302302
* Used by rsc payload generation by Rails
303303
*/
304304
// eslint-disable-next-line @typescript-eslint/no-unused-vars
305-
serverRenderRSCReactComponent(options: RenderParams): PassThrough {
305+
serverRenderRSCReactComponent(_): Readable {
306306
throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only.');
307307
},
308308

node_package/src/ReactOnRailsRSC.ts

Lines changed: 51 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
// @ts-expect-error will define this module types later
2-
import { renderToReadableStream } from 'react-server-dom-webpack/server.edge';
3-
import { PassThrough } from 'stream';
1+
import { renderToPipeableStream } from 'react-server-dom-webpack/server.node';
2+
import { PassThrough, Readable } from 'stream';
3+
import type { ReactElement } from 'react';
44
import fs from 'fs';
55

66
import { RenderParams } from './types';
7-
import ComponentRegistry from './ComponentRegistry';
8-
import createReactOutput from './createReactOutput';
9-
import { isPromise, isServerRenderHash } from './isServerRenderResult';
107
import ReactOnRails from './ReactOnRails';
8+
import buildConsoleReplay from './buildConsoleReplay';
9+
import handleError from './handleError';
10+
import {
11+
streamServerRenderedComponent,
12+
type StreamRenderState,
13+
transformRenderStreamChunksToResultObject,
14+
convertToError,
15+
createResultObject,
16+
} from './serverRenderReactComponent';
1117

1218
const stringToStream = (str: string) => {
1319
const stream = new PassThrough();
@@ -16,68 +22,51 @@ const stringToStream = (str: string) => {
1622
return stream;
1723
};
1824

19-
const getBundleConfig = () => {
20-
const bundleConfig = JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'));
21-
// remove file:// from keys
22-
const newBundleConfig: { [key: string]: unknown } = {};
23-
for (const [key, value] of Object.entries(bundleConfig)) {
24-
newBundleConfig[key.replace('file://', '')] = value;
25-
}
26-
return newBundleConfig;
27-
}
28-
29-
ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
30-
const { name, domNodeId, trace, props, railsContext, throwJsErrors } = options;
25+
const getBundleConfig = () => JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'))
3126

32-
let renderResult: null | PassThrough = null;
27+
const streamRenderRSCComponent = (reactElement: ReactElement, options: RenderParams): Readable => {
28+
const { throwJsErrors } = options;
29+
const renderState: StreamRenderState = {
30+
result: null,
31+
hasErrors: false,
32+
isShellReady: true
33+
};
3334

35+
const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState);
3436
try {
35-
const componentObj = ComponentRegistry.get(name);
36-
if (componentObj.isRenderer) {
37-
throw new Error(`\
38-
Detected a renderer while server rendering component '${name}'. \
39-
See https://github.com/shakacode/react_on_rails#renderer-functions`);
40-
}
41-
42-
const reactRenderingResult = createReactOutput({
43-
componentObj,
44-
domNodeId,
45-
trace,
46-
props,
47-
railsContext,
48-
});
49-
50-
if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
51-
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
52-
}
53-
54-
renderResult = new PassThrough();
55-
let finalValue = "";
56-
const streamReader = renderToReadableStream(reactRenderingResult, getBundleConfig()).getReader();
57-
const decoder = new TextDecoder();
58-
const processStream = async () => {
59-
const { done, value } = await streamReader.read();
60-
if (done) {
61-
renderResult?.push(null);
62-
// @ts-expect-error value is not typed
63-
debugConsole.log('value', finalValue);
64-
return;
37+
const rscStream = renderToPipeableStream(
38+
reactElement,
39+
getBundleConfig(),
40+
{
41+
onError: (err) => {
42+
const error = convertToError(err);
43+
console.error("Error in RSC stream", error);
44+
if (throwJsErrors) {
45+
emitError(error);
46+
}
47+
renderState.hasErrors = true;
48+
renderState.error = error;
49+
}
6550
}
66-
67-
finalValue += decoder.decode(value);
68-
renderResult?.push(value);
69-
processStream();
70-
}
71-
processStream();
72-
} catch (e: unknown) {
73-
if (throwJsErrors) {
74-
throw e;
75-
}
76-
77-
renderResult = stringToStream(`Error: ${e}`);
51+
);
52+
pipeToTransform(rscStream);
53+
return readableStream;
54+
} catch (e) {
55+
const error = convertToError(e);
56+
renderState.hasErrors = true;
57+
renderState.error = error;
58+
const htmlResult = handleError({ e: error, name: options.name, serverSide: true });
59+
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), renderState));
60+
return stringToStream(jsonResult);
7861
}
62+
};
7963

80-
return renderResult;
64+
ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
65+
try {
66+
return streamServerRenderedComponent(options, streamRenderRSCComponent);
67+
} finally {
68+
console.history = [];
69+
}
8170
};
8271

8372
export * from './types';

node_package/src/serverRenderReactComponent.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type RenderState = {
1515
error?: RenderingError;
1616
};
1717

18-
type StreamRenderState = Omit<RenderState, 'result'> & {
18+
export type StreamRenderState = Omit<RenderState, 'result'> & {
1919
result: null | Readable;
2020
isShellReady: boolean;
2121
};
@@ -27,7 +27,7 @@ type RenderOptions = {
2727
renderingReturnsPromises: boolean;
2828
};
2929

30-
function convertToError(e: unknown): Error {
30+
export function convertToError(e: unknown): Error {
3131
return e instanceof Error ? e : new Error(String(e));
3232
}
3333

@@ -104,7 +104,7 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
104104
};
105105
}
106106

107-
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
107+
export function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
108108
return {
109109
html,
110110
consoleReplayScript,
@@ -210,7 +210,7 @@ const stringToStream = (str: string): Readable => {
210210
return stream;
211211
};
212212

213-
const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
213+
export const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
214214
const consoleHistory = console.history;
215215
let previouslyReplayedConsoleMessages = 0;
216216

@@ -298,7 +298,15 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options:
298298
return readableStream;
299299
}
300300

301-
export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
301+
export type StreamRenderer<T> = (
302+
reactElement: ReactElement,
303+
options: RenderParams
304+
) => T;
305+
306+
export const streamServerRenderedComponent = <T>(
307+
options: RenderParams,
308+
renderStrategy: StreamRenderer<T>
309+
): T => {
302310
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
303311

304312
try {
@@ -317,7 +325,7 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
317325
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
318326
}
319327

320-
return streamRenderReactComponent(reactRenderingResult, options);
328+
return renderStrategy(reactRenderingResult, options);
321329
} catch (e) {
322330
if (throwJsErrors) {
323331
throw e;
@@ -326,8 +334,11 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
326334
const error = convertToError(e);
327335
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
328336
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
329-
return stringToStream(jsonResult);
337+
return stringToStream(jsonResult) as T;
330338
}
331339
};
332340

341+
// Update the existing streamServerRenderedReactComponent to use the new shared function
342+
export const streamServerRenderedReactComponent = (options: RenderParams): Readable => streamServerRenderedComponent(options, streamRenderReactComponent);
343+
333344
export default serverRenderReactComponent;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream) {
2+
return new ReadableStream({
3+
async start(controller) {
4+
const reader = stream.getReader();
5+
const decoder = new TextDecoder();
6+
const encoder = new TextEncoder();
7+
8+
let { value, done } = await reader.read();
9+
while (!done) {
10+
const decodedValue = decoder.decode(value);
11+
const jsonChunks = decodedValue.split('\n')
12+
.filter(line => line.trim() !== '')
13+
.map((line) => {
14+
try {
15+
return JSON.parse(line);
16+
} catch (error) {
17+
console.error('Error parsing JSON:', line, error);
18+
throw error;
19+
}
20+
});
21+
22+
for (const jsonChunk of jsonChunks) {
23+
const { html, consoleReplayScript } = jsonChunk;
24+
controller.enqueue(encoder.encode(html));
25+
26+
const replayConsoleCode = consoleReplayScript?.trim().replace(/^<script.*>/, '').replace(/<\/script>$/, '');
27+
if (replayConsoleCode?.trim() !== '') {
28+
const scriptElement = document.createElement('script');
29+
scriptElement.textContent = replayConsoleCode;
30+
document.body.appendChild(scriptElement);
31+
}
32+
}
33+
34+
// eslint-disable-next-line no-await-in-loop
35+
({ value, done } = await reader.read());
36+
}
37+
controller.close();
38+
}
39+
});
40+
}

node_package/src/types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export interface ReactOnRails {
181181
getOrWaitForComponent(name: string): Promise<RegisteredComponent>;
182182
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult>;
183183
streamServerRenderedReactComponent(options: RenderParams): Readable;
184-
serverRenderRSCReactComponent(options: RenderParams): PassThrough;
184+
serverRenderRSCReactComponent(options: RenderParams): Readable;
185185
handleError(options: ErrorOptions): string | undefined;
186186
buildConsoleReplay(): string;
187187
registeredComponents(): Map<string, RegisteredComponent>;

node_package/types/react-server-dom-webpack.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
declare module 'react-server-dom-webpack/server.node' {
2+
export interface Options {
3+
environmentName?: string;
4+
onError?: (error: unknown) => void;
5+
onPostpone?: (reason: string) => void;
6+
identifierPrefix?: string;
7+
}
8+
9+
export interface PipeableStream {
10+
abort(reason: unknown): void;
11+
pipe<Writable extends NodeJS.WritableStream>(destination: Writable): Writable;
12+
}
13+
14+
// Note: ReactClientValue is likely what React uses internally for RSC
15+
// We're using 'unknown' here as it's the most accurate type we can use
16+
// without accessing React's internal types
17+
export function renderToPipeableStream(
18+
model: unknown,
19+
webpackMap: { [key: string]: unknown },
20+
options?: Options
21+
): PipeableStream;
22+
}
23+
124
declare module 'react-server-dom-webpack/client' {
225
export const createFromFetch: (promise: Promise<Response>) => Promise<unknown>;
326

0 commit comments

Comments
 (0)