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 23 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
Show all changes
23 commits
Select commit Hold shift + click to select a range
1744fa0
stream rsc payload in json objects like streamed react components
AbanoubGhadban Dec 11, 2024
7defffd
make path to rsc bundel and react client manifest configurable
AbanoubGhadban Dec 12, 2024
c293d9b
feat: Improve client manifest path handling for dev server
AbanoubGhadban Jan 12, 2025
7244049
fix: normalize RSC URL path by absorbing leading/trailing slashes
AbanoubGhadban Jan 13, 2025
1307f19
specify Shakapacker as top-level module
Judahmeek Jan 17, 2025
f711ef8
rubocop linting
Judahmeek Jan 17, 2025
a293c2b
fix main CI failures
Judahmeek Jan 17, 2025
f2c3eea
require Shakapacker so constant will be defined
Judahmeek Jan 17, 2025
90577da
try to fix remaining main CI failures
Judahmeek Jan 17, 2025
3c0b1f6
attempt dynamic constant
Judahmeek Jan 17, 2025
bd506aa
add tests for RSCClientRoot
AbanoubGhadban Jan 20, 2025
5bafa2a
linting
AbanoubGhadban Jan 26, 2025
48ca653
Make RSCClientRoot tests run with react 18
AbanoubGhadban Jan 27, 2025
85aa863
linting
AbanoubGhadban Jan 27, 2025
298eb18
Update test specs to use dynamic ReactOnRails version
AbanoubGhadban Jan 27, 2025
3c0101c
tmp
AbanoubGhadban Jan 27, 2025
7cbd252
Revert "tmp"
AbanoubGhadban Jan 27, 2025
8193f1c
Revert "Update test specs to use dynamic ReactOnRails version"
AbanoubGhadban Jan 27, 2025
0322557
Revert "try to fix remaining main CI failures"
AbanoubGhadban Jan 27, 2025
04ebeda
Revert "fix main CI failures"
AbanoubGhadban Jan 27, 2025
11bfcb6
fix syntax error
AbanoubGhadban Jan 27, 2025
a2d4d84
linting
AbanoubGhadban Jan 27, 2025
411207c
Update webpack asset path configuration for client manifest
AbanoubGhadban Jan 27, 2025
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
3 changes: 3 additions & 0 deletions .github/workflows/package-js-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ jobs:
sudo yarn global add yalc
- name: Run JS unit tests for Renderer package
run: yarn test
# TODO: Remove this once we made these tests compatible with React 19
- name: Run JS unit tests for Renderer package with React 18 (for tests not compatible with React 19)
run: yarn test:react-18
6 changes: 0 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,6 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
webpacker (6.0.0.rc.6)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket (1.2.10)
websocket-driver (0.7.6)
Expand Down Expand Up @@ -444,7 +439,6 @@ DEPENDENCIES
turbolinks
uglifier
webdrivers (= 5.3.0)
webpacker (= 6.0.0.rc.6)

BUNDLED WITH
2.5.9
11 changes: 11 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@ module.exports = {
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
// TODO: Remove this once we made RSCClientRoot compatible with React 19
moduleNameMapper: process.env.USE_REACT_18
? {
'^react$': '<rootDir>/node_modules/react-18',
'^react/(.*)$': '<rootDir>/node_modules/react-18/$1',
'^react-dom$': '<rootDir>/node_modules/react-dom-18',
'^react-dom/(.*)$': '<rootDir>/node_modules/react-dom-18/$1',
}
: {
'react-server-dom-webpack/client': '<rootDir>/node_package/tests/emptyForTesting.js',
},
};
4 changes: 4 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const config: KnipConfig = {
'eslint-plugin-jsx-a11y',
'eslint-plugin-react',
'react-server-dom-webpack',
'cross-fetch',
'jsdom',
'react-18',
'react-dom-18',
],
},
'spec/dummy': {
Expand Down
11 changes: 8 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def self.configure
end

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

def self.configuration
@configuration ||= Configuration.new(
Expand All @@ -18,6 +19,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 @@ -57,8 +59,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
:defer_generated_component_packs, :force_load, :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 @@ -74,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: 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)
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 Down Expand Up @@ -102,6 +104,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 @@ -247,6 +250,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
21 changes: 20 additions & 1 deletion lib/react_on_rails/packer_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ def self.dev_server_running?
packer.dev_server.running?
end

def self.dev_server_url
"#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}"
end

def self.shakapacker_version
return @shakapacker_version if defined?(@shakapacker_version)
return nil unless ReactOnRails::Utils.gem_available?("shakapacker")
Expand Down Expand Up @@ -79,12 +83,27 @@ def self.bundle_js_uri_from_packer(bundle_name)

if packer.dev_server.running? && (!is_bundle_running_on_server ||
ReactOnRails.configuration.same_bundle_for_client_and_server)
"#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}#{hashed_bundle_name}"
"#{dev_server_url}#{hashed_bundle_name}"
else
File.expand_path(File.join("public", hashed_bundle_name)).to_s
end
end

def self.public_output_uri_path
"#{packer.config.public_output_path.relative_path_from(packer.config.public_path)}/"
end

# The function doesn't ensure that the asset exists.
# - It just returns url to the asset if dev server is running
# - Otherwise it returns file path to the asset
def self.asset_uri_from_packer(asset_name)
if dev_server_running?
"#{dev_server_url}/#{public_output_uri_path}#{asset_name}"
else
File.join(packer_public_output_path, asset_name).to_s
end
end

def self.precompile?
return ::Webpacker.config.webpacker_precompile? if using_webpacker_const?
return ::Shakapacker.config.shakapacker_precompile? if using_shakapacker_const?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,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
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ def stale_generated_files(files)
def all_compiled_assets
@all_compiled_assets ||= begin
webpack_generated_files = @webpack_generated_files.map do |bundle_name|
if bundle_name == ReactOnRails.configuration.server_bundle_js_file
ReactOnRails::Utils.server_bundle_js_file_path
if bundle_name == ReactOnRails.configuration.react_client_manifest_file
ReactOnRails::Utils.react_client_manifest_file_path
else
ReactOnRails::Utils.bundle_js_file_path(bundle_name)
end
Expand Down
11 changes: 11 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ 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 = if ReactOnRails::PackerUtils.using_packer?
ReactOnRails::PackerUtils.asset_uri_from_packer(file_name)
else
File.join(generated_assets_full_path, file_name)
end
end

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

if (!('use' in React)) {
if (!('use' in React && typeof React.use === 'function')) {
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.');
}

const { use } = React;

const renderCache: Record<string, Promise<React.ReactNode>> = {};
let renderCache: Record<string, Promise<React.ReactNode>> = {};
export const resetRenderCache = () => {
renderCache = {};
}

export type RSCClientRootProps = {
componentName: string;
rscRenderingUrlPath: string;
}

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, rscRenderingUrlPath }: RSCClientRootProps) => {
if (!renderCache[componentName]) {
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`${rscRenderingUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
const strippedUrlPath = rscRenderingUrlPath.replace(/^\/|\/$/g, '');
renderCache[componentName] = createFromFetch(fetch(`/${strippedUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
}
return renderCache[componentName];
}
Expand Down
119 changes: 54 additions & 65 deletions node_package/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
// @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, StreamRenderState } from './types';
import ReactOnRails from './ReactOnRails';
import buildConsoleReplay from './buildConsoleReplay';
import handleError from './handleError';
import {
convertToError,
createResultObject,
} from './serverRenderUtils';

import {
streamServerRenderedComponent,
transformRenderStreamChunksToResultObject,
} from './streamServerRenderedReactComponent';
import loadReactClientManifest from './loadReactClientManifest';

const stringToStream = (str: string) => {
const stream = new PassThrough();
Expand All @@ -16,68 +24,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
Loading
Loading