Skip to content

Commit c4cc9d2

Browse files
stream rsc payload in json objects like streamed react components
1 parent c676342 commit c4cc9d2

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
@@ -429,7 +429,7 @@ def internal_rsc_react_component(react_component_name, options = {})
429429
render_options = create_render_options(react_component_name, options)
430430
json_stream = server_rendered_react_component(render_options)
431431
json_stream.transform do |chunk|
432-
chunk[:html].html_safe
432+
(chunk.to_json + "\n").html_safe
433433
end
434434
end
435435

@@ -701,10 +701,7 @@ def server_rendered_react_component(render_options)
701701
js_code: js_code)
702702
end
703703

704-
# TODO: handle errors for rsc streams
705-
return result if render_options.rsc?
706-
707-
if render_options.stream?
704+
if render_options.stream? || render_options.rsc?
708705
result.transform do |chunk_json_result|
709706
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
710707
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.3.0-canary-670811593-20240322 or later to use server components.');
@@ -11,9 +12,19 @@ const { use } = React as { use: Use };
1112

1213
const renderCache: Record<string, Promise<unknown>> = {};
1314

15+
const createFromFetch = async (fetchPromise: Promise<Response>) => {
16+
const response = await fetchPromise;
17+
const stream = response.body;
18+
if (!stream) {
19+
throw new Error('No stream found in response');
20+
}
21+
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
22+
return RSDWClient.createFromReadableStream(transformedStream);
23+
}
24+
1425
const fetchRSC = ({ componentName }: { componentName: string }) => {
1526
if (!renderCache[componentName]) {
16-
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`));
27+
renderCache[componentName] = createFromFetch(fetch(`/rsc/${componentName}`));
1728
}
1829
return renderCache[componentName];
1930
}

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 handleError from './handleError';
@@ -306,7 +306,7 @@ ctx.ReactOnRails = {
306306
* @param options
307307
*/
308308
// eslint-disable-next-line @typescript-eslint/no-unused-vars
309-
serverRenderRSCReactComponent(options: RenderParams): PassThrough {
309+
serverRenderRSCReactComponent(_): Readable {
310310
throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only.');
311311
},
312312

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
(async () => {
1319
try {
@@ -25,68 +31,51 @@ const stringToStream = (str: string) => {
2531
return stream;
2632
};
2733

28-
const getBundleConfig = () => {
29-
const bundleConfig = JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'));
30-
// remove file:// from keys
31-
const newBundleConfig: { [key: string]: unknown } = {};
32-
for (const [key, value] of Object.entries(bundleConfig)) {
33-
newBundleConfig[key.replace('file://', '')] = value;
34-
}
35-
return newBundleConfig;
36-
}
37-
38-
ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
39-
const { name, domNodeId, trace, props, railsContext, throwJsErrors } = options;
34+
const getBundleConfig = () => JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'))
4035

41-
let renderResult: null | PassThrough = null;
36+
const streamRenderRSCComponent = (reactElement: ReactElement, options: RenderParams): Readable => {
37+
const { throwJsErrors } = options;
38+
const renderState: StreamRenderState = {
39+
result: null,
40+
hasErrors: false,
41+
isShellReady: true
42+
};
4243

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

89-
return renderResult;
73+
ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
74+
try {
75+
return streamServerRenderedComponent(options, streamRenderRSCComponent);
76+
} finally {
77+
console.history = [];
78+
}
9079
};
9180

9281
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
@@ -177,7 +177,7 @@ export interface ReactOnRails {
177177
getOrWaitForComponent(name: string): Promise<RegisteredComponent>;
178178
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult>;
179179
streamServerRenderedReactComponent(options: RenderParams): Readable;
180-
serverRenderRSCReactComponent(options: RenderParams): PassThrough;
180+
serverRenderRSCReactComponent(options: RenderParams): Readable;
181181
handleError(options: ErrorOptions): string | undefined;
182182
buildConsoleReplay(): string;
183183
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)