Skip to content

Commit 06609b0

Browse files
stream rsc payload in json objects like streamed react components
1 parent 58fd819 commit 06609b0

File tree

8 files changed

+149
-76
lines changed

8 files changed

+149
-76
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) # rubocop:disable Metrics/Cy
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,6 @@ def file_url_to_string(url)
234234
end
235235

236236
def parse_result_and_replay_console_messages(result_string, render_options)
237-
return { html: result_string } if render_options.rsc?
238-
239237
result = nil
240238
begin
241239
result = JSON.parse(result_string)

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.');
@@ -14,9 +15,19 @@ export type RSCClientRootProps = {
1415
rscRenderingUrlPath: string;
1516
}
1617

18+
const createFromFetch = async (fetchPromise: Promise<Response>) => {
19+
const response = await fetchPromise;
20+
const stream = response.body;
21+
if (!stream) {
22+
throw new Error('No stream found in response');
23+
}
24+
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
25+
return RSDWClient.createFromReadableStream(transformedStream);
26+
}
27+
1728
const fetchRSC = ({ componentName, rscRenderingUrlPath }: RSCClientRootProps) => {
1829
if (!renderCache[componentName]) {
19-
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`${rscRenderingUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
30+
renderCache[componentName] = createFromFetch(fetch(`${rscRenderingUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
2031
}
2132
return renderCache[componentName];
2233
}

node_package/src/ReactOnRailsRSC.ts

Lines changed: 57 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
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

6-
import { RenderParams } from './types';
7-
import ComponentRegistry from './ComponentRegistry';
8-
import createReactOutput from './createReactOutput';
9-
import { isPromise, isServerRenderHash } from './isServerRenderResult';
6+
import {
7+
RenderParams,
8+
StreamRenderState,
9+
} from './types';
1010
import ReactOnRails from './ReactOnRails';
11+
import buildConsoleReplay from './buildConsoleReplay';
12+
import handleError from './handleError';
13+
import {
14+
convertToError,
15+
createResultObject,
16+
} from './serverRenderUtils';
17+
18+
import {
19+
streamServerRenderedComponent,
20+
transformRenderStreamChunksToResultObject,
21+
} from './streamServerRenderedReactComponent';
1122

1223
const stringToStream = (str: string) => {
1324
const stream = new PassThrough();
@@ -16,68 +27,51 @@ const stringToStream = (str: string) => {
1627
return stream;
1728
};
1829

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;
30+
const getBundleConfig = () => JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'))
3131

32-
let renderResult: null | PassThrough = null;
32+
const streamRenderRSCComponent = (reactElement: ReactElement, options: RenderParams): Readable => {
33+
const { throwJsErrors } = options;
34+
const renderState: StreamRenderState = {
35+
result: null,
36+
hasErrors: false,
37+
isShellReady: true
38+
};
3339

40+
const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState);
3441
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;
42+
const rscStream = renderToPipeableStream(
43+
reactElement,
44+
getBundleConfig(),
45+
{
46+
onError: (err) => {
47+
const error = convertToError(err);
48+
console.error("Error in RSC stream", error);
49+
if (throwJsErrors) {
50+
emitError(error);
51+
}
52+
renderState.hasErrors = true;
53+
renderState.error = error;
54+
}
6555
}
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}`);
56+
);
57+
pipeToTransform(rscStream);
58+
return readableStream;
59+
} catch (e) {
60+
const error = convertToError(e);
61+
renderState.hasErrors = true;
62+
renderState.error = error;
63+
const htmlResult = handleError({ e: error, name: options.name, serverSide: true });
64+
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), renderState));
65+
return stringToStream(jsonResult);
7866
}
67+
};
7968

80-
return renderResult;
69+
ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
70+
try {
71+
return streamServerRenderedComponent(options, streamRenderRSCComponent);
72+
} finally {
73+
console.history = [];
74+
}
8175
};
8276

8377
export * from './types';

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const stringToStream = (str: string): Readable => {
1717
return stream;
1818
};
1919

20-
const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
20+
export const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
2121
const consoleHistory = console.history;
2222
let previouslyReplayedConsoleMessages = 0;
2323

@@ -105,7 +105,15 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options:
105105
return readableStream;
106106
}
107107

108-
const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
108+
export type StreamRenderer<T> = (
109+
reactElement: ReactElement,
110+
options: RenderParams
111+
) => T;
112+
113+
export const streamServerRenderedComponent = <T>(
114+
options: RenderParams,
115+
renderStrategy: StreamRenderer<T>
116+
): T => {
109117
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
110118

111119
try {
@@ -124,7 +132,7 @@ const streamServerRenderedReactComponent = (options: RenderParams): Readable =>
124132
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
125133
}
126134

127-
return streamRenderReactComponent(reactRenderingResult, options);
135+
return renderStrategy(reactRenderingResult, options);
128136
} catch (e) {
129137
if (throwJsErrors) {
130138
throw e;
@@ -133,8 +141,10 @@ const streamServerRenderedReactComponent = (options: RenderParams): Readable =>
133141
const error = convertToError(e);
134142
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
135143
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
136-
return stringToStream(jsonResult);
144+
return stringToStream(jsonResult) as T;
137145
}
138146
};
139147

148+
const streamServerRenderedReactComponent = (options: RenderParams): Readable => streamServerRenderedComponent(options, streamRenderReactComponent);
149+
140150
export default streamServerRenderedReactComponent;
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
@@ -185,7 +185,7 @@ export interface ReactOnRails {
185185
getOrWaitForComponent(name: string): Promise<RegisteredComponent>;
186186
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult>;
187187
streamServerRenderedReactComponent(options: RenderParams): Readable;
188-
serverRenderRSCReactComponent(options: RenderParams): PassThrough;
188+
serverRenderRSCReactComponent(options: RenderParams): Readable;
189189
handleError(options: ErrorOptions): string | undefined;
190190
buildConsoleReplay(): string;
191191
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
@@ -15,6 +15,29 @@ declare module 'react-server-dom-webpack/node-loader' {
1515
): Promise<LoadResult>;
1616
}
1717

18+
declare module 'react-server-dom-webpack/server.node' {
19+
export interface Options {
20+
environmentName?: string;
21+
onError?: (error: unknown) => void;
22+
onPostpone?: (reason: string) => void;
23+
identifierPrefix?: string;
24+
}
25+
26+
export interface PipeableStream {
27+
abort(reason: unknown): void;
28+
pipe<Writable extends NodeJS.WritableStream>(destination: Writable): Writable;
29+
}
30+
31+
// Note: ReactClientValue is likely what React uses internally for RSC
32+
// We're using 'unknown' here as it's the most accurate type we can use
33+
// without accessing React's internal types
34+
export function renderToPipeableStream(
35+
model: unknown,
36+
webpackMap: { [key: string]: unknown },
37+
options?: Options
38+
): PipeableStream;
39+
}
40+
1841
declare module 'react-server-dom-webpack/client' {
1942
export const createFromFetch: (promise: Promise<Response>) => Promise<unknown>;
2043

0 commit comments

Comments
 (0)