Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] handle errors happen in rsc payload #1663

Draft
wants to merge 3 commits into
base: abanoubghadban/pro425/hydrate-components-immediately-after-downloading-chunks
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def self.configure

DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze
DEFAULT_RSC_RENDERING_URL = "rsc/".freeze
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json".freeze

def self.configuration
@configuration ||= Configuration.new(
Expand All @@ -19,6 +20,7 @@ def self.configuration
generated_assets_dir: "",
server_bundle_js_file: "",
rsc_bundle_js_file: "",
react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE,
prerender: false,
auto_load_bundle: false,
replay_console: true,
Expand Down Expand Up @@ -59,8 +61,8 @@ class Configuration
:server_render_method, :random_dom_id, :auto_load_bundle,
:same_bundle_for_client_and_server, :rendering_props_extension,
:make_generated_server_bundle_the_entrypoint,
:defer_generated_component_packs, :rsc_bundle_js_file,
:force_load, :auto_load_server_components, :rsc_rendering_url
:defer_generated_component_packs, :force_load, :auto_load_server_components,
:rsc_rendering_url, :rsc_bundle_js_file, :react_client_manifest_file

# rubocop:disable Metrics/AbcSize
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
Expand All @@ -76,7 +78,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil,
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
rsc_bundle_js_file: nil, auto_load_server_components: nil, rsc_rendering_url: nil)
auto_load_server_components: nil, rsc_rendering_url: nil, rsc_bundle_js_file: nil,
react_client_manifest_file: nil)
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
self.generated_assets_dirs = generated_assets_dirs
self.generated_assets_dir = generated_assets_dir
Expand All @@ -103,6 +106,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
# Server rendering:
self.server_bundle_js_file = server_bundle_js_file
self.rsc_bundle_js_file = rsc_bundle_js_file
self.react_client_manifest_file = react_client_manifest_file
self.same_bundle_for_client_and_server = same_bundle_for_client_and_server
self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size
self.server_renderer_timeout = server_renderer_timeout # seconds
Expand Down Expand Up @@ -250,6 +254,8 @@ def ensure_webpack_generated_files_exists
files = ["manifest.json"]
files << server_bundle_js_file if server_bundle_js_file.present?
files << rsc_bundle_js_file if rsc_bundle_js_file.present?
files << react_client_manifest_file if react_client_manifest_file.present?

self.webpack_generated_files = files
end

Expand Down
7 changes: 2 additions & 5 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ def internal_rsc_react_component(react_component_name, options = {})
render_options = create_render_options(react_component_name, options)
json_stream = server_rendered_react_component(render_options)
json_stream.transform do |chunk|
chunk[:html].html_safe
(chunk.to_json + "\n").html_safe
end
end

Expand Down Expand Up @@ -691,10 +691,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
js_code: js_code)
end

# TODO: handle errors for rsc streams
return result if render_options.rsc?

if render_options.stream?
if render_options.stream? || render_options.rsc?
result.transform do |chunk_json_result|
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,6 @@ def file_url_to_string(url)
end

def parse_result_and_replay_console_messages(result_string, render_options)
return { html: result_string } if render_options.rsc?

result = nil
begin
result = JSON.parse(result_string)
Expand Down
7 changes: 7 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ def self.rsc_bundle_js_file_path
@rsc_bundle_path = bundle_js_file_path(bundle_name)
end

def self.react_client_manifest_file_path
return @react_client_manifest_path if @react_client_manifest_path && !Rails.env.development?

file_name = ReactOnRails.configuration.react_client_manifest_file
@react_client_manifest_path = bundle_js_file_path(file_name)
end

def self.running_on_windows?
(/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
end
Expand Down
13 changes: 12 additions & 1 deletion node_package/src/RSCClientRoot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import RSDWClient from 'react-server-dom-webpack/client';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';

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

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

const createFromFetch = async (fetchPromise: Promise<Response>) => {
const response = await fetchPromise;
const stream = response.body;
if (!stream) {
throw new Error('No stream found in response');
}
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
return RSDWClient.createFromReadableStream(transformedStream);
}

const fetchRSC = ({ componentName }: { componentName: string }) => {
if (!renderCache[componentName]) {
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)) as Promise<React.ReactNode>;
renderCache[componentName] = createFromFetch(fetch(`/rsc/${componentName}`)) as Promise<React.ReactNode>;
}
return renderCache[componentName];
}
Expand Down
5 changes: 2 additions & 3 deletions node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
import type { Readable, PassThrough } from 'stream';
import type { Readable } from 'stream';

import * as ClientStartup from './clientStartup';
import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer';
Expand Down Expand Up @@ -301,8 +301,7 @@ ctx.ReactOnRails = {
/**
* Used by rsc payload generation by Rails
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
serverRenderRSCReactComponent(options: RenderParams): PassThrough {
serverRenderRSCReactComponent(): Readable {
throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only.');
},

Expand Down
117 changes: 52 additions & 65 deletions node_package/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
// @ts-expect-error will define this module types later
import { renderToReadableStream } from 'react-server-dom-webpack/server.edge';
import { PassThrough } from 'stream';
import fs from 'fs';
import { renderToPipeableStream } from 'react-server-dom-webpack/server.node';
import { PassThrough, Readable } from 'stream';
import type { ReactElement } from 'react';

import { RenderParams } from './types';
import ComponentRegistry from './ComponentRegistry';
import createReactOutput from './createReactOutput';
import { isPromise, isServerRenderHash } from './isServerRenderResult';
import { RSCRenderParams } from './types';
import ReactOnRails from './ReactOnRails';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import {
streamServerRenderedComponent,
type StreamRenderState,
transformRenderStreamChunksToResultObject,
convertToError,
createResultObject,
} from './serverRenderReactComponent';
import loadReactClientManifest from './loadReactClientManifest';

const stringToStream = (str: string) => {
const stream = new PassThrough();
Expand All @@ -16,68 +22,49 @@ const stringToStream = (str: string) => {
return stream;
};

const getBundleConfig = () => {
const bundleConfig = JSON.parse(fs.readFileSync('./public/webpack/development/react-client-manifest.json', 'utf8'));
// remove file:// from keys
const newBundleConfig: { [key: string]: unknown } = {};
for (const [key, value] of Object.entries(bundleConfig)) {
newBundleConfig[key.replace('file://', '')] = value;
}
return newBundleConfig;
}

ReactOnRails.serverRenderRSCReactComponent = (options: RenderParams) => {
const { name, domNodeId, trace, props, railsContext, throwJsErrors } = options;

let renderResult: null | PassThrough = null;
const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRenderParams): Readable => {
const { throwJsErrors, reactClientManifestFileName } = options;
const renderState: StreamRenderState = {
result: null,
hasErrors: false,
isShellReady: true
};

const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState);
try {
const componentObj = ComponentRegistry.get(name);
if (componentObj.isRenderer) {
throw new Error(`\
Detected a renderer while server rendering component '${name}'. \
See https://github.com/shakacode/react_on_rails#renderer-functions`);
}

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.');
}

renderResult = new PassThrough();
let finalValue = "";
const streamReader = renderToReadableStream(reactRenderingResult, getBundleConfig()).getReader();
const decoder = new TextDecoder();
const processStream = async () => {
const { done, value } = await streamReader.read();
if (done) {
renderResult?.push(null);
// @ts-expect-error value is not typed
debugConsole.log('value', finalValue);
return;
const rscStream = renderToPipeableStream(
reactElement,
loadReactClientManifest(reactClientManifestFileName),
{
onError: (err) => {
const error = convertToError(err);
console.error("Error in RSC stream", error);
if (throwJsErrors) {
emitError(error);
}
renderState.hasErrors = true;
renderState.error = error;
}
}

finalValue += decoder.decode(value);
renderResult?.push(value);
processStream();
}
processStream();
} catch (e: unknown) {
if (throwJsErrors) {
throw e;
}

renderResult = stringToStream(`Error: ${e}`);
);
pipeToTransform(rscStream);
return readableStream;
} catch (e) {
const error = convertToError(e);
renderState.hasErrors = true;
renderState.error = error;
const htmlResult = handleError({ e: error, name: options.name, serverSide: true });
const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), renderState));
return stringToStream(jsonResult);
}
};

return renderResult;
ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
try {
return streamServerRenderedComponent(options, streamRenderRSCComponent);
} finally {
console.history = [];
}
};

export * from './types';
Expand Down
17 changes: 17 additions & 0 deletions node_package/src/loadReactClientManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import path from 'path';
import fs from 'fs';

const loadedReactClientManifests = new Map<string, { [key: string]: unknown; }>();

export default function loadReactClientManifest(reactClientManifestFileName: string) {
// React client manifest is uploaded to node renderer as an asset.
// Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js.
// Thus, the __dirname of this code is where we can find the manifest file.
const manifestPath = path.resolve(__dirname, reactClientManifestFileName);
if (!loadedReactClientManifests.has(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
loadedReactClientManifests.set(manifestPath, manifest);
}

return loadedReactClientManifests.get(manifestPath)!;
}
25 changes: 18 additions & 7 deletions node_package/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type RenderState = {
error?: RenderingError;
};

type StreamRenderState = Omit<RenderState, 'result'> & {
export type StreamRenderState = Omit<RenderState, 'result'> & {
result: null | Readable;
isShellReady: boolean;
};
Expand All @@ -27,7 +27,7 @@ type RenderOptions = {
renderingReturnsPromises: boolean;
};

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

Expand Down Expand Up @@ -104,7 +104,7 @@ function handleRenderingError(e: unknown, options: { componentName: string, thro
};
}

function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
export function createResultObject(html: string | null, consoleReplayScript: string, renderState: RenderState | StreamRenderState): RenderResult {
return {
html,
consoleReplayScript,
Expand Down Expand Up @@ -210,7 +210,7 @@ const stringToStream = (str: string): Readable => {
return stream;
};

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

Expand Down Expand Up @@ -298,7 +298,15 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options:
return readableStream;
}

export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
export type StreamRenderer<T, P extends RenderParams> = (
reactElement: ReactElement,
options: P
) => T;

export const streamServerRenderedComponent = <T, P extends RenderParams>(
options: P,
renderStrategy: StreamRenderer<T, P>
): T => {
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;

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

return streamRenderReactComponent(reactRenderingResult, options);
return renderStrategy(reactRenderingResult, options);
} catch (e) {
if (throwJsErrors) {
throw e;
Expand All @@ -326,8 +334,11 @@ export const streamServerRenderedReactComponent = (options: RenderParams): Reada
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);
return stringToStream(jsonResult) as T;
}
};

// Update the existing streamServerRenderedReactComponent to use the new shared function
export const streamServerRenderedReactComponent = (options: RenderParams): Readable => streamServerRenderedComponent(options, streamRenderReactComponent);

export default serverRenderReactComponent;
Loading
Loading