Skip to content

Commit

Permalink
Separate streamServerRenderedReactComponent from ReactOnRails (#1680)
Browse files Browse the repository at this point in the history
* separate streamServerRenderedReactComponent from ReactOnRails

* add changelog entry

* Export stream functions only for node bundles (#1681)

* Add export for ReactOnRails for Node.js which supports streaming

* adding default export from ReactOnRails module in ReactOnRails.node

---------

Co-authored-by: Abanoub Ghadban <abanoub@shakacode.com>
  • Loading branch information
Judahmeek and AbanoubGhadban authored Jan 16, 2025
1 parent fc789d9 commit 846d02d
Show file tree
Hide file tree
Showing 13 changed files with 213 additions and 183 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
### [Unreleased]
Changes since the last non-beta release.

### [14.1.1] - 2025-01-15

#### Fixed

- Separated streamServerRenderedReactComponent from the ReactOnRails object in order to stop users from getting errors during webpack compilation about needing the `stream-browserify` package. [PR 1680](https://github.com/shakacode/react_on_rails/pull/1680) by [judahmeek](https://github.com/judahmeek).

### [14.1.0] - 2025-01-06

#### Fixed
Expand Down
7 changes: 7 additions & 0 deletions node_package/src/ReactOnRails.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import ReactOnRails from './ReactOnRails';
import streamServerRenderedReactComponent from './streamServerRenderedReactComponent';

ReactOnRails.streamServerRenderedReactComponent = streamServerRenderedReactComponent;

export * from './ReactOnRails';
export { default } from './ReactOnRails';
7 changes: 3 additions & 4 deletions node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { ReactElement } from 'react';
import type { Readable } from 'stream';

import * as ClientStartup from './clientStartup';
import handleError from './handleError';
import ComponentRegistry from './ComponentRegistry';
import StoreRegistry from './StoreRegistry';
import serverRenderReactComponent, { streamServerRenderedReactComponent } from './serverRenderReactComponent';
import serverRenderReactComponent from './serverRenderReactComponent';
import buildConsoleReplay from './buildConsoleReplay';
import createReactOutput from './createReactOutput';
import Authenticity from './Authenticity';
Expand Down Expand Up @@ -252,8 +251,8 @@ ctx.ReactOnRails = {
* Used by server rendering by Rails
* @param options
*/
streamServerRenderedReactComponent(options: RenderParams): Readable {
return streamServerRenderedReactComponent(options);
streamServerRenderedReactComponent() {
throw new Error('streamServerRenderedReactComponent is only supported when using a bundle built for Node.js environments');
},

/**
Expand Down
171 changes: 3 additions & 168 deletions node_package/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,13 @@
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
import { PassThrough, Readable } from 'stream';
import ReactDOMServer from 'react-dom/server';
import type { ReactElement } from 'react';

import ComponentRegistry from './ComponentRegistry';
import createReactOutput from './createReactOutput';
import { isPromise, isServerRenderHash } from './isServerRenderResult';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import type { CreateReactOutputResult, RegisteredComponent, RenderParams, RenderResult, RenderingError, ServerRenderResult } from './types';

type RenderState = {
result: null | string | Promise<string>;
hasErrors: boolean;
error?: RenderingError;
};

type StreamRenderState = Omit<RenderState, 'result'> & {
result: null | Readable;
isShellReady: boolean;
};

type RenderOptions = {
componentName: string;
domNodeId?: string;
trace?: boolean;
renderingReturnsPromises: boolean;
};

function convertToError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}

function validateComponent(componentObj: RegisteredComponent, componentName: string) {
if (componentObj.isRenderer) {
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
}
}
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
import type { CreateReactOutputResult, RenderParams, RenderResult, RenderState, RenderOptions, ServerRenderResult } from './types';

function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState {
const { redirectLocation, routeError } = result;
Expand Down Expand Up @@ -104,16 +76,6 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
};
}

function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
return {
html,
consoleReplayScript,
hasErrors: renderState.hasErrors,
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
};
}

async function createPromiseResult(
renderState: RenderState & { result: Promise<string> },
componentName: string,
Expand Down Expand Up @@ -203,131 +165,4 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
}
};

const stringToStream = (str: string): Readable => {
const stream = new PassThrough();
stream.write(str);
stream.end();
return stream;
};

const transformRenderStreamChunksToResultObject = (renderState: StreamRenderState) => {
const consoleHistory = console.history;
let previouslyReplayedConsoleMessages = 0;

const transformStream = new PassThrough({
transform(chunk, _, callback) {
const htmlChunk = chunk.toString();
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;

const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));

this.push(`${jsonChunk}\n`);
callback();
}
});

let pipedStream: PipeableStream | null = null;
const pipeToTransform = (pipeableStream: PipeableStream) => {
pipeableStream.pipe(transformStream);
pipedStream = pipeableStream;
};
// We need to wrap the transformStream in a Readable stream to properly handle errors:
// 1. If we returned transformStream directly, we couldn't emit errors into it externally
// 2. If an error is emitted into the transformStream, it would cause the render to fail
// 3. By wrapping in Readable.from(), we can explicitly emit errors into the readableStream without affecting the transformStream
// Note: Readable.from can merge multiple chunks into a single chunk, so we need to ensure that we can separate them later
const readableStream = Readable.from(transformStream);

const writeChunk = (chunk: string) => transformStream.write(chunk);
const emitError = (error: unknown) => readableStream.emit('error', error);
const endStream = () => {
transformStream.end();
pipedStream?.abort();
}
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
}

const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => {
const { name: componentName, throwJsErrors } = options;
const renderState: StreamRenderState = {
result: null,
hasErrors: false,
isShellReady: false
};

const {
readableStream,
pipeToTransform,
writeChunk,
emitError,
endStream
} = transformRenderStreamChunksToResultObject(renderState);

const renderingStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult, {
onShellError(e) {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;

if (throwJsErrors) {
emitError(error);
}

const errorHtml = handleError({ e: error, name: componentName, serverSide: true });
writeChunk(errorHtml);
endStream();
},
onShellReady() {
renderState.isShellReady = true;
pipeToTransform(renderingStream);
},
onError(e) {
if (!renderState.isShellReady) {
return;
}
const error = convertToError(e);
if (throwJsErrors) {
emitError(error);
}
renderState.hasErrors = true;
renderState.error = error;
},
});

return readableStream;
}

export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;

try {
const componentObj = ComponentRegistry.get(componentName);
validateComponent(componentObj, componentName);

const reactRenderingResult = createReactOutput({
componentObj,
domNodeId,
trace,
props,
railsContext,
});

if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
}

return streamRenderReactComponent(reactRenderingResult, options);
} catch (e) {
if (throwJsErrors) {
throw e;
}

const error = convertToError(e);
const htmlResult = handleError({ e: error, name: componentName, serverSide: true });
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), { hasErrors: true, error, result: null }));
return stringToStream(jsonResult);
}
};

export default serverRenderReactComponent;
22 changes: 22 additions & 0 deletions node_package/src/serverRenderUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

import type { RegisteredComponent, RenderResult, RenderState, StreamRenderState } from './types';

export function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
return {
html,
consoleReplayScript,
hasErrors: renderState.hasErrors,
renderingError: renderState.error && { message: renderState.error.message, stack: renderState.error.stack },
isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
};
}

export function convertToError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}

export function validateComponent(componentObj: RegisteredComponent, componentName: string) {
if (componentObj.isRenderer) {
throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
}
}
Loading

0 comments on commit 846d02d

Please sign in to comment.