Skip to content

Commit e1c9641

Browse files
committed
separate streamServerRenderedReactComponent from ReactOnRails
1 parent fc789d9 commit e1c9641

File tree

8 files changed

+185
-182
lines changed

8 files changed

+185
-182
lines changed

node_package/src/ReactOnRails.ts

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

43
import * as ClientStartup from './clientStartup';
54
import handleError from './handleError';
65
import ComponentRegistry from './ComponentRegistry';
76
import StoreRegistry from './StoreRegistry';
8-
import serverRenderReactComponent, { streamServerRenderedReactComponent } from './serverRenderReactComponent';
7+
import serverRenderReactComponent from './serverRenderReactComponent';
98
import buildConsoleReplay from './buildConsoleReplay';
109
import createReactOutput from './createReactOutput';
1110
import Authenticity from './Authenticity';
@@ -248,14 +247,6 @@ ctx.ReactOnRails = {
248247
return serverRenderReactComponent(options);
249248
},
250249

251-
/**
252-
* Used by server rendering by Rails
253-
* @param options
254-
*/
255-
streamServerRenderedReactComponent(options: RenderParams): Readable {
256-
return streamServerRenderedReactComponent(options);
257-
},
258-
259250
/**
260251
* Used by Rails to catch errors in rendering
261252
* @param options
Lines changed: 3 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,13 @@
1-
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
2-
import { PassThrough, Readable } from 'stream';
1+
import ReactDOMServer from 'react-dom/server';
32
import type { ReactElement } from 'react';
43

54
import ComponentRegistry from './ComponentRegistry';
65
import createReactOutput from './createReactOutput';
76
import { isPromise, isServerRenderHash } from './isServerRenderResult';
87
import buildConsoleReplay from './buildConsoleReplay';
98
import handleError from './handleError';
10-
import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types';
11-
12-
type RenderState = {
13-
result: null | string | Promise<string>;
14-
hasErrors: boolean;
15-
error?: RenderingError;
16-
};
17-
18-
type StreamRenderState = Omit<RenderState, 'result'> & {
19-
result: null | Readable;
20-
isShellReady: boolean;
21-
};
22-
23-
type RenderOptions = {
24-
componentName: string;
25-
domNodeId?: string;
26-
trace?: boolean;
27-
renderingReturnsPromises: boolean;
28-
};
29-
30-
function convertToError(e: unknown): Error {
31-
return e instanceof Error ? e : new Error(String(e));
32-
}
33-
34-
function validateComponent(componentObj: RegisteredComponent, componentName: string) {
35-
if (componentObj.isRenderer) {
36-
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
37-
}
38-
}
9+
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
10+
import type { CreateReactOutputResult, RenderParams, RenderResult, RenderState, RenderOptions, ServerRenderResult } from './types';
3911

4012
function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState {
4113
const { redirectLocation, routeError } = result;
@@ -104,16 +76,6 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
10476
};
10577
}
10678

107-
function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
108-
return {
109-
html,
110-
consoleReplayScript,
111-
hasErrors: renderState.hasErrors,
112-
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
113-
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
114-
};
115-
}
116-
11779
async function createPromiseResult(
11880
renderState: RenderState & { result: Promise<string> },
11981
componentName: string,
@@ -203,131 +165,4 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
203165
}
204166
};
205167

206-
const stringToStream = (str: string): Readable => {
207-
const stream = new PassThrough();
208-
stream.write(str);
209-
stream.end();
210-
return stream;
211-
};
212-
213-
const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
214-
const consoleHistory = console.history;
215-
let previouslyReplayedConsoleMessages = 0;
216-
217-
const transformStream = new PassThrough({
218-
transform(chunk, _, callback) {
219-
const htmlChunk = chunk.toString();
220-
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
221-
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
222-
223-
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
224-
225-
this.push(`${jsonChunk}\n`);
226-
callback();
227-
}
228-
});
229-
230-
let pipedStream: PipeableStream | null = null;
231-
const pipeToTransform = (pipeableStream: PipeableStream) => {
232-
pipeableStream.pipe(transformStream);
233-
pipedStream = pipeableStream;
234-
};
235-
// We need to wrap the transformStream in a Readable stream to properly handle errors:
236-
// 1. If we returned transformStream directly, we couldn't emit errors into it externally
237-
// 2. If an error is emitted into the transformStream, it would cause the render to fail
238-
// 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
239-
// Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
240-
const readableStream = Readable.from(transformStream);
241-
242-
const writeChunk = (chunk: string) => transformStream.write(chunk);
243-
const emitError = (error: unknown) => readableStream.emit('error', error);
244-
const endStream = () => {
245-
transformStream.end();
246-
pipedStream?.abort();
247-
}
248-
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
249-
}
250-
251-
const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
252-
const { name: componentName, throwJsErrors } = options;
253-
const renderState: StreamRenderState = {
254-
result: null,
255-
hasErrors: false,
256-
isShellReady: false
257-
};
258-
259-
const {
260-
readableStream,
261-
pipeToTransform,
262-
writeChunk,
263-
emitError,
264-
endStream
265-
} = transformRenderStreamChunksToResultObject(renderState);
266-
267-
const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
268-
onShellError(e) {
269-
const error = convertToError(e);
270-
renderState.hasErrors = true;
271-
renderState.error = error;
272-
273-
if (throwJsErrors) {
274-
emitError(error);
275-
}
276-
277-
const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
278-
writeChunk(errorHtml);
279-
endStream();
280-
},
281-
onShellReady() {
282-
renderState.isShellReady = true;
283-
pipeToTransform(renderingStream);
284-
},
285-
onError(e) {
286-
if (!renderState.isShellReady) {
287-
return;
288-
}
289-
const error = convertToError(e);
290-
if (throwJsErrors) {
291-
emitError(error);
292-
}
293-
renderState.hasErrors = true;
294-
renderState.error = error;
295-
},
296-
});
297-
298-
return readableStream;
299-
}
300-
301-
export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
302-
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
303-
304-
try {
305-
const componentObj = ComponentRegistry.get(componentName);
306-
validateComponent(componentObj, componentName);
307-
308-
const reactRenderingResult = createReactOutput({
309-
componentObj,
310-
domNodeId,
311-
trace,
312-
props,
313-
railsContext,
314-
});
315-
316-
if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
317-
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
318-
}
319-
320-
return streamRenderReactComponent(reactRenderingResult, options);
321-
} catch (e) {
322-
if (throwJsErrors) {
323-
throw e;
324-
}
325-
326-
const error = convertToError(e);
327-
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
328-
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
329-
return stringToStream(jsonResult);
330-
}
331-
};
332-
333168
export default serverRenderReactComponent;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
2+
import type { RegisteredComponent, RenderResult, RenderState, StreamRenderState } from './types';
3+
4+
export function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
5+
return {
6+
html,
7+
consoleReplayScript,
8+
hasErrors: renderState.hasErrors,
9+
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
10+
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
11+
};
12+
}
13+
14+
export function convertToError(e: unknown): Error {
15+
return e instanceof Error ? e : new Error(String(e));
16+
}
17+
18+
export function validateComponent(componentObj: RegisteredComponent, componentName: string) {
19+
if (componentObj.isRenderer) {
20+
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
21+
}
22+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
2+
import { PassThrough, Readable } from 'stream';
3+
import type { ReactElement } from 'react';
4+
5+
import ComponentRegistry from './ComponentRegistry';
6+
import createReactOutput from './createReactOutput';
7+
import { isPromise, isServerRenderHash } from './isServerRenderResult';
8+
import buildConsoleReplay from './buildConsoleReplay';
9+
import handleError from './handleError';
10+
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
11+
import type { RenderParams, StreamRenderState } from './types';
12+
13+
const stringToStream = (str: string): Readable => {
14+
const stream = new PassThrough();
15+
stream.write(str);
16+
stream.end();
17+
return stream;
18+
};
19+
20+
const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
21+
const consoleHistory = console.history;
22+
let previouslyReplayedConsoleMessages = 0;
23+
24+
const transformStream = new PassThrough({
25+
transform(chunk, _, callback) {
26+
const htmlChunk = chunk.toString();
27+
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
28+
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
29+
30+
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
31+
32+
this.push(`${jsonChunk}\n`);
33+
callback();
34+
}
35+
});
36+
37+
let pipedStream: PipeableStream | null = null;
38+
const pipeToTransform = (pipeableStream: PipeableStream) => {
39+
pipeableStream.pipe(transformStream);
40+
pipedStream = pipeableStream;
41+
};
42+
// We need to wrap the transformStream in a Readable stream to properly handle errors:
43+
// 1. If we returned transformStream directly, we couldn't emit errors into it externally
44+
// 2. If an error is emitted into the transformStream, it would cause the render to fail
45+
// 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
46+
// Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
47+
const readableStream = Readable.from(transformStream);
48+
49+
const writeChunk = (chunk: string) => transformStream.write(chunk);
50+
const emitError = (error: unknown) => readableStream.emit('error', error);
51+
const endStream = () => {
52+
transformStream.end();
53+
pipedStream?.abort();
54+
}
55+
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
56+
}
57+
58+
const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
59+
const { name: componentName, throwJsErrors } = options;
60+
const renderState: StreamRenderState = {
61+
result: null,
62+
hasErrors: false,
63+
isShellReady: false
64+
};
65+
66+
const {
67+
readableStream,
68+
pipeToTransform,
69+
writeChunk,
70+
emitError,
71+
endStream
72+
} = transformRenderStreamChunksToResultObject(renderState);
73+
74+
const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
75+
onShellError(e) {
76+
const error = convertToError(e);
77+
renderState.hasErrors = true;
78+
renderState.error = error;
79+
80+
if (throwJsErrors) {
81+
emitError(error);
82+
}
83+
84+
const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
85+
writeChunk(errorHtml);
86+
endStream();
87+
},
88+
onShellReady() {
89+
renderState.isShellReady = true;
90+
pipeToTransform(renderingStream);
91+
},
92+
onError(e) {
93+
if (!renderState.isShellReady) {
94+
return;
95+
}
96+
const error = convertToError(e);
97+
if (throwJsErrors) {
98+
emitError(error);
99+
}
100+
renderState.hasErrors = true;
101+
renderState.error = error;
102+
},
103+
});
104+
105+
return readableStream;
106+
}
107+
108+
const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
109+
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
110+
111+
try {
112+
const componentObj = ComponentRegistry.get(componentName);
113+
validateComponent(componentObj, componentName);
114+
115+
const reactRenderingResult = createReactOutput({
116+
componentObj,
117+
domNodeId,
118+
trace,
119+
props,
120+
railsContext,
121+
});
122+
123+
if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
124+
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
125+
}
126+
127+
return streamRenderReactComponent(reactRenderingResult, options);
128+
} catch (e) {
129+
if (throwJsErrors) {
130+
throw e;
131+
}
132+
133+
const error = convertToError(e);
134+
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
135+
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
136+
return stringToStream(jsonResult);
137+
}
138+
};
139+
140+
export default streamServerRenderedReactComponent;

0 commit comments

Comments
 (0)