From efbca351c4023e5b401967faeaa3006565331288 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 14:13:28 +0300 Subject: [PATCH 01/13] don't include react dom server at the rsc bundle --- packages/react-on-rails-pro/jest.config.js | 4 +- .../react-on-rails-pro/src/ReactOnRailsRSC.ts | 9 ++--- .../react-on-rails-pro/src/handleError.ts | 12 ++++++ .../react-on-rails-pro/src/handleErrorRSC.ts | 15 ++++++++ .../src/streamServerRenderedReactComponent.ts | 15 ++++---- .../react-on-rails-pro/src/streamingUtils.ts | 10 ++--- .../tests/ReactOnRailsRSC.test.tsx | 13 +++++++ packages/react-on-rails/package.json | 8 +++- packages/react-on-rails/src/base/full.rsc.ts | 38 +++++++++++++++++++ packages/react-on-rails/src/base/full.ts | 2 +- .../src/generateRenderingErrorMessage.ts | 12 +----- 11 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 packages/react-on-rails-pro/src/handleError.ts create mode 100644 packages/react-on-rails-pro/src/handleErrorRSC.ts create mode 100644 packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx create mode 100644 packages/react-on-rails/src/base/full.rsc.ts diff --git a/packages/react-on-rails-pro/jest.config.js b/packages/react-on-rails-pro/jest.config.js index 10964867aa..150b2efa56 100644 --- a/packages/react-on-rails-pro/jest.config.js +++ b/packages/react-on-rails-pro/jest.config.js @@ -28,7 +28,9 @@ export default { // Allow Jest to transform react-on-rails package from node_modules transformIgnorePatterns: ['node_modules/(?!react-on-rails)'], - + testEnvironmentOptions: { + customExportConditions: process.env.NODE_CONDITIONS?.split(',') ?? [], + }, // Set root directory to current package rootDir: '.', }; diff --git a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts index ba71a7ad68..a233c156e9 100644 --- a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts +++ b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts @@ -22,8 +22,8 @@ import { StreamRenderState, StreamableComponentResult, } from 'react-on-rails/types'; -import handleError from 'react-on-rails/handleError'; import { convertToError } from 'react-on-rails/serverRenderUtils'; +import handleError from './handleErrorRSC.ts'; import ReactOnRails from './ReactOnRails.full.ts'; import { @@ -51,7 +51,7 @@ const streamRenderRSCComponent = ( isShellReady: true, }; - const { pipeToTransform, readableStream, emitError, writeChunk, endStream } = + const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState); const reportError = (error: Error) => { @@ -87,8 +87,7 @@ const streamRenderRSCComponent = ( const error = convertToError(e); reportError(error); const errorHtml = handleError({ e: error, name: options.name, serverSide: true }); - writeChunk(errorHtml); - endStream(); + pipeToTransform(errorHtml); }); readableStream.on('end', () => { @@ -99,7 +98,7 @@ const streamRenderRSCComponent = ( ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { try { - return streamServerRenderedComponent(options, streamRenderRSCComponent); + return streamServerRenderedComponent(options, streamRenderRSCComponent, handleError); } finally { console.history = []; } diff --git a/packages/react-on-rails-pro/src/handleError.ts b/packages/react-on-rails-pro/src/handleError.ts new file mode 100644 index 0000000000..d6e1b85a3d --- /dev/null +++ b/packages/react-on-rails-pro/src/handleError.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { ErrorOptions } from 'react-on-rails/types'; +import { renderToPipeableStream } from 'react-on-rails/ReactDOMServer'; +import generateRenderingErrorMessage from 'react-on-rails/generateRenderingErrorMessage'; + +const handleError = (options: ErrorOptions) => { + const msg = generateRenderingErrorMessage(options); + const reactElement = React.createElement('pre', null, msg); + return renderToPipeableStream(reactElement); +}; + +export default handleError; diff --git a/packages/react-on-rails-pro/src/handleErrorRSC.ts b/packages/react-on-rails-pro/src/handleErrorRSC.ts new file mode 100644 index 0000000000..6d41eb94e6 --- /dev/null +++ b/packages/react-on-rails-pro/src/handleErrorRSC.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { ErrorOptions } from 'react-on-rails/types'; +import { renderToPipeableStream } from 'react-on-rails-rsc/server.node'; +import generateRenderingErrorMessage from 'react-on-rails/generateRenderingErrorMessage'; + +const handleError = (options: ErrorOptions) => { + const msg = generateRenderingErrorMessage(options); + const reactElement = React.createElement('pre', null, msg); + return renderToPipeableStream(reactElement, { + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }); +}; + +export default handleError; diff --git a/packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts b/packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts index efc359d6a8..de14f2966d 100644 --- a/packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts +++ b/packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts @@ -14,7 +14,6 @@ import { Readable } from 'stream'; -import handleError from 'react-on-rails/handleError'; import { renderToPipeableStream } from 'react-on-rails/ReactDOMServer'; import { convertToError } from 'react-on-rails/serverRenderUtils'; import { @@ -24,11 +23,12 @@ import { StreamableComponentResult, } from 'react-on-rails/types'; import injectRSCPayload from './injectRSCPayload.ts'; -import { +import { + streamServerRenderedComponent, StreamingTrackers, transformRenderStreamChunksToResultObject, - streamServerRenderedComponent, - } from './streamingUtils.ts'; +} from './streamingUtils.ts'; +import handleError from './handleError.ts'; const streamRenderReactComponent = ( reactRenderingResult: StreamableComponentResult, @@ -55,9 +55,8 @@ const streamRenderReactComponent = ( }; const sendErrorHtml = (error: Error) => { - const errorHtml = handleError({ e: error, name: componentName, serverSide: true }); - writeChunk(errorHtml); - endStream(); + const errorHtmlStream = handleError({ e: error, name: componentName, serverSide: true }); + pipeToTransform(errorHtmlStream); }; assertRailsContextWithServerStreamingCapabilities(railsContext); @@ -102,6 +101,6 @@ const streamRenderReactComponent = ( }; const streamServerRenderedReactComponent = (options: RenderParams): Readable => - streamServerRenderedComponent(options, streamRenderReactComponent); + streamServerRenderedComponent(options, streamRenderReactComponent, handleError); export default streamServerRenderedReactComponent; diff --git a/packages/react-on-rails-pro/src/streamingUtils.ts b/packages/react-on-rails-pro/src/streamingUtils.ts index cf0de591dd..f4d6c76eec 100644 --- a/packages/react-on-rails-pro/src/streamingUtils.ts +++ b/packages/react-on-rails-pro/src/streamingUtils.ts @@ -18,7 +18,6 @@ import { PassThrough, Readable } from 'stream'; import createReactOutput from 'react-on-rails/createReactOutput'; import { isPromise, isServerRenderHash } from 'react-on-rails/isServerRenderResult'; import buildConsoleReplay from 'react-on-rails/buildConsoleReplay'; -import handleError from 'react-on-rails/handleError'; import { createResultObject, convertToError, validateComponent } from 'react-on-rails/serverRenderUtils'; import { RenderParams, @@ -27,6 +26,7 @@ import { PipeableOrReadableStream, RailsContextWithServerStreamingCapabilities, assertRailsContextWithServerComponentMetadata, + ErrorOptions, } from 'react-on-rails/types'; import * as ComponentRegistry from './ComponentRegistry.ts'; import PostSSRHookTracker from './PostSSRHookTracker.ts'; @@ -179,6 +179,7 @@ type StreamRenderer = ( export const streamServerRenderedComponent = ( options: P, renderStrategy: StreamRenderer, + handleError: (options: ErrorOptions) => PipeableOrReadableStream, ): T => { const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; @@ -233,7 +234,7 @@ export const streamServerRenderedComponent = ( return renderStrategy(reactRenderingResult, optionsWithStreamingCapabilities, streamingTrackers); } catch (e) { - const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject({ + const { readableStream, pipeToTransform, emitError } = transformRenderStreamChunksToResultObject({ hasErrors: true, isShellReady: false, result: null, @@ -243,9 +244,8 @@ export const streamServerRenderedComponent = ( } const error = convertToError(e); - const htmlResult = handleError({ e: error, name: componentName, serverSide: true }); - writeChunk(htmlResult); - endStream(); + const htmlResultStream = handleError({ e: error, name: componentName, serverSide: true }); + pipeToTransform(htmlResultStream); return readableStream as T; } }; diff --git a/packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx b/packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx new file mode 100644 index 0000000000..3d8d5330a3 --- /dev/null +++ b/packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx @@ -0,0 +1,13 @@ +jest.mock('react-dom/server', () => { + throw new Error("ReactOnRailsRSC shouldn't import react-dom/server at all"); +}); + +test('import ReactOnRailsRSC', async () => { + await expect(import('../src/ReactOnRailsRSC.ts')).resolves.toEqual( + expect.objectContaining({ + default: expect.objectContaining({ + isRSCBundle: true, + }) as unknown, + }), + ); +}); diff --git a/packages/react-on-rails/package.json b/packages/react-on-rails/package.json index 8a3a8280ba..6161c32500 100644 --- a/packages/react-on-rails/package.json +++ b/packages/react-on-rails/package.json @@ -46,13 +46,17 @@ "./isRenderFunction": "./lib/isRenderFunction.js", "./ReactOnRails.client": "./lib/ReactOnRails.client.js", "./ReactOnRails.full": "./lib/ReactOnRails.full.js", - "./handleError": "./lib/generateRenderingErrorMessage.js", + "./handleError": "./lib/handleError.js", + "./generateRenderingErrorMessage": "./lib/generateRenderingErrorMessage.js", "./serverRenderUtils": "./lib/serverRenderUtils.js", "./buildConsoleReplay": "./lib/buildConsoleReplay.js", "./ReactDOMServer": "./lib/ReactDOMServer.cjs", "./serverRenderReactComponent": "./lib/serverRenderReactComponent.js", "./@internal/base/client": "./lib/base/client.js", - "./@internal/base/full": "./lib/base/full.js" + "./@internal/base/full": { + "react-server": "./lib/base/full.rsc.js", + "default": "./lib/base/full.js" + } }, "peerDependencies": { "react": ">= 16", diff --git a/packages/react-on-rails/src/base/full.rsc.ts b/packages/react-on-rails/src/base/full.rsc.ts new file mode 100644 index 0000000000..734daffc85 --- /dev/null +++ b/packages/react-on-rails/src/base/full.rsc.ts @@ -0,0 +1,38 @@ +import { createBaseClientObject, type BaseClientObjectType } from './client.ts'; +import type { BaseFullObjectType, ReactOnRailsFullSpecificFunctions } from './full.ts'; + +export type * from './full.ts'; + +export function createBaseFullObject( + registries: Parameters[0], + currentObject: BaseClientObjectType | null = null, +): BaseFullObjectType { + // Get or create client object (with caching logic) + const clientObject = createBaseClientObject(registries, currentObject); + + // Define SSR-specific functions with proper types + // This object acts as a type-safe specification of what we're adding to the base object + const reactOnRailsFullSpecificFunctions: ReactOnRailsFullSpecificFunctions = { + handleError() { + throw new Error('"handleError" function is not supported in RSC bundle'); + }, + + serverRenderReactComponent() { + throw new Error('"serverRenderReactComponent" function is not supported in RSC bundle'); + }, + }; + + // Type assertion is safe here because: + // 1. We start with BaseClientObjectType (from createBaseClientObject) + // 2. We add exactly the methods defined in ReactOnRailsFullSpecificFunctions + // 3. BaseFullObjectType = BaseClientObjectType + ReactOnRailsFullSpecificFunctions + // TypeScript can't track the mutation, but we ensure type safety by explicitly typing + // the functions object above + const fullObject = clientObject as unknown as BaseFullObjectType; + + // Assign SSR-specific functions to the full object using Object.assign + // This pattern ensures we add exactly what's defined in the type, nothing more, nothing less + Object.assign(fullObject, reactOnRailsFullSpecificFunctions); + + return fullObject; +} diff --git a/packages/react-on-rails/src/base/full.ts b/packages/react-on-rails/src/base/full.ts index 5dbfc4c321..29b4f6f843 100644 --- a/packages/react-on-rails/src/base/full.ts +++ b/packages/react-on-rails/src/base/full.ts @@ -16,7 +16,7 @@ if (typeof window !== 'undefined') { * SSR-specific functions that extend the base client object to create a full object. * Typed explicitly to ensure type safety when mutating the base object. */ -type ReactOnRailsFullSpecificFunctions = Pick< +export type ReactOnRailsFullSpecificFunctions = Pick< ReactOnRailsInternal, 'handleError' | 'serverRenderReactComponent' >; diff --git a/packages/react-on-rails/src/generateRenderingErrorMessage.ts b/packages/react-on-rails/src/generateRenderingErrorMessage.ts index eb9c3b12c9..492c7c6cd3 100644 --- a/packages/react-on-rails/src/generateRenderingErrorMessage.ts +++ b/packages/react-on-rails/src/generateRenderingErrorMessage.ts @@ -1,5 +1,3 @@ -import * as React from 'react'; -import { renderToString } from './ReactDOMServer.cts'; import type { ErrorOptions } from './types/index.ts'; function handleRenderFunctionIssue(options: ErrorOptions): string { @@ -34,7 +32,7 @@ but the React component '${name}' is not a Render-Function.\n${lastLine}`; return msg; } -const handleError = (options: ErrorOptions): string => { +const generateRenderingErrorMessage = (options: ErrorOptions): string => { const { e, jsCode, serverSide } = options; console.error('Exception in rendering!'); @@ -59,16 +57,10 @@ Message: ${e.message} ${e.stack}`; - // In RSC (React Server Components) bundles, renderToString is not available. - // Therefore, we return the raw error message as a string instead of converting it to HTML. - if (typeof renderToString === 'function') { - const reactElement = React.createElement('pre', null, msg); - return renderToString(reactElement); - } return msg; } return 'undefined'; }; -export default handleError; +export default generateRenderingErrorMessage; From efaa96f8e80db1eadca355e29b16007db7888e0e Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 16:10:45 +0300 Subject: [PATCH 02/13] serialize error during rsc payload generation at the RSC payload --- packages/react-on-rails-pro/src/RSCRoute.tsx | 10 +++++++++- packages/react-on-rails-pro/src/handleErrorRSC.ts | 4 +--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/react-on-rails-pro/src/RSCRoute.tsx b/packages/react-on-rails-pro/src/RSCRoute.tsx index 1c514b8cf0..daea978587 100644 --- a/packages/react-on-rails-pro/src/RSCRoute.tsx +++ b/packages/react-on-rails-pro/src/RSCRoute.tsx @@ -77,7 +77,15 @@ export type RSCRouteProps = { const PromiseWrapper = ({ promise }: { promise: Promise }) => { // React.use is available in React 18.3+ - return React.use(promise); + const promiseResult = React.use(promise); + + // In case that an error happened during the rendering of the RSC payload before the rendering of the component itself starts + // RSC bundle will return an error object serilaized inside the RSC payload + if (promiseResult instanceof Error) { + throw promiseResult; + } + + return promiseResult; }; const RSCRoute = ({ componentName, componentProps }: RSCRouteProps): React.ReactNode => { diff --git a/packages/react-on-rails-pro/src/handleErrorRSC.ts b/packages/react-on-rails-pro/src/handleErrorRSC.ts index 6d41eb94e6..8286d3feb9 100644 --- a/packages/react-on-rails-pro/src/handleErrorRSC.ts +++ b/packages/react-on-rails-pro/src/handleErrorRSC.ts @@ -1,12 +1,10 @@ -import * as React from 'react'; import { ErrorOptions } from 'react-on-rails/types'; import { renderToPipeableStream } from 'react-on-rails-rsc/server.node'; import generateRenderingErrorMessage from 'react-on-rails/generateRenderingErrorMessage'; const handleError = (options: ErrorOptions) => { const msg = generateRenderingErrorMessage(options); - const reactElement = React.createElement('pre', null, msg); - return renderToPipeableStream(reactElement, { + return renderToPipeableStream(new Error(msg), { filePathToModuleMetadata: {}, moduleLoading: { prefix: '', crossOrigin: null }, }); From 224751f45ab22b18568a4326bdc74ae2b8bb6f0d Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 16:21:32 +0300 Subject: [PATCH 03/13] return error while streaming in one chunk --- packages/react-on-rails-pro/src/handleError.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-on-rails-pro/src/handleError.ts b/packages/react-on-rails-pro/src/handleError.ts index d6e1b85a3d..0b0435f8a9 100644 --- a/packages/react-on-rails-pro/src/handleError.ts +++ b/packages/react-on-rails-pro/src/handleError.ts @@ -1,12 +1,14 @@ import * as React from 'react'; +import { Readable } from 'stream'; import { ErrorOptions } from 'react-on-rails/types'; -import { renderToPipeableStream } from 'react-on-rails/ReactDOMServer'; +import { renderToString } from 'react-on-rails/ReactDOMServer'; import generateRenderingErrorMessage from 'react-on-rails/generateRenderingErrorMessage'; const handleError = (options: ErrorOptions) => { const msg = generateRenderingErrorMessage(options); const reactElement = React.createElement('pre', null, msg); - return renderToPipeableStream(reactElement); + const htmlString = renderToString(reactElement); + return Readable.from([htmlString]); }; export default handleError; From e86b8aabe20d4111f1eaf9b87a4e278dcd50f466 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 16:47:45 +0300 Subject: [PATCH 04/13] fix the running scripts during oldest tests --- packages/react-on-rails-pro/jest.config.js | 6 +++--- packages/react-on-rails-pro/package.json | 3 ++- script/convert | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/react-on-rails-pro/jest.config.js b/packages/react-on-rails-pro/jest.config.js index 150b2efa56..4aacf17f5b 100644 --- a/packages/react-on-rails-pro/jest.config.js +++ b/packages/react-on-rails-pro/jest.config.js @@ -28,9 +28,9 @@ export default { // Allow Jest to transform react-on-rails package from node_modules transformIgnorePatterns: ['node_modules/(?!react-on-rails)'], - testEnvironmentOptions: { - customExportConditions: process.env.NODE_CONDITIONS?.split(',') ?? [], - }, + testEnvironmentOptions: !!process.env.NODE_CONDITIONS ? { + customExportConditions: process.env.NODE_CONDITIONS.split(','), + } : {}, // Set root directory to current package rootDir: '.', }; diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index 4378d0c103..0a47436a8e 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -7,7 +7,8 @@ "build": "yarn run clean && yarn run tsc", "build-watch": "yarn run clean && yarn run tsc --watch", "clean": "rm -rf ./lib", - "test": "jest tests", + "test": "jest tests --testPathIgnorePatterns=\".*(ReactOnRailsRSC).*\"", + "test-rsc": "NODE_CONDITIONS=react-server jest tests/ReactOnRailsRSC.test.tsx", "type-check": "yarn run tsc --noEmit --noErrorTruncation", "prepack": "nps build.prepack", "prepare": "nps build.prepack", diff --git a/script/convert b/script/convert index 74f66beb47..06d6f30beb 100755 --- a/script/convert +++ b/script/convert @@ -40,10 +40,16 @@ gsub_file_content("../spec/dummy/package.json", /"react": "[^"]*",/, '"react": " gsub_file_content("../spec/dummy/package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') gsub_file_content( "../packages/react-on-rails-pro/package.json", - "jest tests", + "jest tests --testPathIgnorePatterns=\\\".*(ReactOnRailsRSC).*\\\"", 'jest tests --testPathIgnorePatterns=\".*(RSC|stream|' \ 'registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"' ) +# Make test-rsc script do nothing +gsub_file_content( + "../packages/react-on-rails-pro/package.json", + /"test-rsc": "(?:\\"|[^"])*",/, + '"test-rsc": "exit 0",', +) # Keep modern JSX transform for React 18+ # gsub_file_content("../tsconfig.json", "react-jsx", "react") # gsub_file_content("../spec/dummy/babel.config.js", "runtime: 'automatic'", "runtime: 'classic'") From 690ed308c9a33f40e60877c26b322ee5d1a5b20c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 16:50:38 +0300 Subject: [PATCH 05/13] linting --- packages/react-on-rails-pro/jest.config.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-on-rails-pro/jest.config.js b/packages/react-on-rails-pro/jest.config.js index 4aacf17f5b..c373c0da4b 100644 --- a/packages/react-on-rails-pro/jest.config.js +++ b/packages/react-on-rails-pro/jest.config.js @@ -28,9 +28,11 @@ export default { // Allow Jest to transform react-on-rails package from node_modules transformIgnorePatterns: ['node_modules/(?!react-on-rails)'], - testEnvironmentOptions: !!process.env.NODE_CONDITIONS ? { - customExportConditions: process.env.NODE_CONDITIONS.split(','), - } : {}, + testEnvironmentOptions: process.env.NODE_CONDITIONS + ? { + customExportConditions: process.env.NODE_CONDITIONS.split(','), + } + : {}, // Set root directory to current package rootDir: '.', }; From 41391e214b9ac86b2dc28cc7093dbd1f02a5d02c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 17:02:10 +0300 Subject: [PATCH 06/13] linting --- packages/react-on-rails-pro/jest.config.js | 2 ++ packages/react-on-rails-pro/src/RSCRoute.tsx | 2 +- script/convert | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-on-rails-pro/jest.config.js b/packages/react-on-rails-pro/jest.config.js index c373c0da4b..747b7a50b0 100644 --- a/packages/react-on-rails-pro/jest.config.js +++ b/packages/react-on-rails-pro/jest.config.js @@ -28,6 +28,8 @@ export default { // Allow Jest to transform react-on-rails package from node_modules transformIgnorePatterns: ['node_modules/(?!react-on-rails)'], + // RSC tests needs the node condition "react-server" to run + // So, before running these tests, we set "NODE_CONDITIONS=react-server" testEnvironmentOptions: process.env.NODE_CONDITIONS ? { customExportConditions: process.env.NODE_CONDITIONS.split(','), diff --git a/packages/react-on-rails-pro/src/RSCRoute.tsx b/packages/react-on-rails-pro/src/RSCRoute.tsx index daea978587..84d2a4b34c 100644 --- a/packages/react-on-rails-pro/src/RSCRoute.tsx +++ b/packages/react-on-rails-pro/src/RSCRoute.tsx @@ -80,7 +80,7 @@ const PromiseWrapper = ({ promise }: { promise: Promise }) => { const promiseResult = React.use(promise); // In case that an error happened during the rendering of the RSC payload before the rendering of the component itself starts - // RSC bundle will return an error object serilaized inside the RSC payload + // RSC bundle will return an error object serialized inside the RSC payload if (promiseResult instanceof Error) { throw promiseResult; } diff --git a/script/convert b/script/convert index 06d6f30beb..fcad3513c8 100755 --- a/script/convert +++ b/script/convert @@ -48,7 +48,7 @@ gsub_file_content( gsub_file_content( "../packages/react-on-rails-pro/package.json", /"test-rsc": "(?:\\"|[^"])*",/, - '"test-rsc": "exit 0",', + '"test-rsc": "exit 0",' ) # Keep modern JSX transform for React 18+ # gsub_file_content("../tsconfig.json", "react-jsx", "react") From 2aacb216670982ba5ed23c10c35c7d960810f1f9 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 17:08:11 +0300 Subject: [PATCH 07/13] add base/full.rsc.ts to knip entry points --- knip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.ts b/knip.ts index 82692470c7..29af90d608 100644 --- a/knip.ts +++ b/knip.ts @@ -42,7 +42,7 @@ const config: KnipConfig = { // React on Rails core package workspace 'packages/react-on-rails': { - entry: ['src/ReactOnRails.full.ts!', 'src/ReactOnRails.client.ts!'], + entry: ['src/ReactOnRails.full.ts!', 'src/ReactOnRails.client.ts!', 'src/base/full.rsc.ts!'], project: ['src/**/*.[jt]s{x,}!', 'tests/**/*.[jt]s{x,}', '!lib/**'], ignore: [ // Jest setup and test utilities - not detected by Jest plugin in workspace setup From fa69233826d8423df5c76ff452edf64094c1958d Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 17:51:01 +0300 Subject: [PATCH 08/13] update changelog.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63264690e..ebe72b2fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ Changes since the last non-beta release. - **Improved Error Messages**: Error messages for version mismatches and package configuration issues now include package-manager-specific installation commands (npm, yarn, pnpm, bun). [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban). +- **Improved RSC Payload Error Handling**: Errors that happen during generation of RSC payload are transferred properly to rails side and logs the error message and stack. [PR #1888](https://github.com/shakacode/react_on_rails/pull/1888) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + #### Bug Fixes - **Use as Git dependency**: All packages can now be installed as Git dependencies. This is useful for development and testing purposes. See [CONTRIBUTING.md](./CONTRIBUTING.md#git-dependencies) for documentation. [PR #1873](https://github.com/shakacode/react_on_rails/pull/1873) by [alexeyr-ci2](https://github.com/alexeyr-ci2). From b39c68a2acce08d52f3be8c0fbdc0affbbaa7555 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 20:42:09 +0300 Subject: [PATCH 09/13] make the test script run rsc and non-rsc tests --- packages/react-on-rails-pro/package.json | 5 +++-- script/convert | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index 0a47436a8e..d574a88673 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -7,8 +7,9 @@ "build": "yarn run clean && yarn run tsc", "build-watch": "yarn run clean && yarn run tsc --watch", "clean": "rm -rf ./lib", - "test": "jest tests --testPathIgnorePatterns=\".*(ReactOnRailsRSC).*\"", - "test-rsc": "NODE_CONDITIONS=react-server jest tests/ReactOnRailsRSC.test.tsx", + "test": "yarn test:non-rsc && yarn test:rsc", + "test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(ReactOnRailsRSC).*\"", + "test:rsc": "NODE_CONDITIONS=react-server jest tests/ReactOnRailsRSC.test.tsx", "type-check": "yarn run tsc --noEmit --noErrorTruncation", "prepack": "nps build.prepack", "prepare": "nps build.prepack", diff --git a/script/convert b/script/convert index fcad3513c8..b68d7c26fa 100755 --- a/script/convert +++ b/script/convert @@ -40,15 +40,15 @@ gsub_file_content("../spec/dummy/package.json", /"react": "[^"]*",/, '"react": " gsub_file_content("../spec/dummy/package.json", /"react-dom": "[^"]*",/, '"react-dom": "18.0.0",') gsub_file_content( "../packages/react-on-rails-pro/package.json", - "jest tests --testPathIgnorePatterns=\\\".*(ReactOnRailsRSC).*\\\"", - 'jest tests --testPathIgnorePatterns=\".*(RSC|stream|' \ - 'registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"' + /"test:non-rsc": "(?:\\"|[^"])*",/, + '"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(RSC|stream|' \ + 'registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"",' ) -# Make test-rsc script do nothing +# Make test:rsc script do nothing gsub_file_content( "../packages/react-on-rails-pro/package.json", - /"test-rsc": "(?:\\"|[^"])*",/, - '"test-rsc": "exit 0",' + /"test:rsc": "(?:\\"|[^"])*",/, + '"test:rsc": "exit 0",' ) # Keep modern JSX transform for React 18+ # gsub_file_content("../tsconfig.json", "react-jsx", "react") From 3412273f6539aa64984a1463985b1021f8e1ce4c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 21:05:44 +0300 Subject: [PATCH 10/13] add tests for RSC serialization --- .../tests/RSCSerialization.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/react-on-rails-pro/tests/RSCSerialization.test.tsx diff --git a/packages/react-on-rails-pro/tests/RSCSerialization.test.tsx b/packages/react-on-rails-pro/tests/RSCSerialization.test.tsx new file mode 100644 index 0000000000..2d9362a9e7 --- /dev/null +++ b/packages/react-on-rails-pro/tests/RSCSerialization.test.tsx @@ -0,0 +1,44 @@ +/** + * @jest-environment node + */ + +import { PassThrough } from 'stream'; +import { buildServerRenderer } from 'react-on-rails-rsc/server.node'; +import { buildClientRenderer } from 'react-on-rails-rsc/client.node'; + +const emptyManifestObject = { + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, +}; + +const { renderToPipeableStream } = buildServerRenderer(emptyManifestObject); +const { createFromNodeStream } = buildClientRenderer(emptyManifestObject, emptyManifestObject); + +test('renderToPipeableStream can encode objects into RSC stream', async () => { + const encodedStream = renderToPipeableStream({ + name: 'Alice', + age: 22, + }); + const readableStream = new PassThrough(); + + encodedStream.pipe(readableStream); + const decodedObject = await createFromNodeStream(readableStream); + expect(decodedObject).toMatchObject({ + name: 'Alice', + age: 22, + }); +}); + +test('renderToPipeableStream can encode Error objects into RSC stream', async () => { + const encodedStream = renderToPipeableStream(new Error('Fake Error')); + const readableStream = new PassThrough(); + + encodedStream.pipe(readableStream); + const decodedObject = await createFromNodeStream(readableStream); + expect(decodedObject).toBeInstanceOf(Error); + expect(decodedObject).toEqual( + expect.objectContaining({ + message: 'Fake Error', + }), + ); +}); From 16472de14473b623e84d79094e2314bc2ff7879e Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 21:14:11 +0300 Subject: [PATCH 11/13] add handleError.ts file --- packages/react-on-rails/src/base/full.ts | 2 +- packages/react-on-rails/src/handleError.ts | 12 ++++++++++++ .../react-on-rails/src/serverRenderReactComponent.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/react-on-rails/src/handleError.ts diff --git a/packages/react-on-rails/src/base/full.ts b/packages/react-on-rails/src/base/full.ts index 29b4f6f843..272304e9e3 100644 --- a/packages/react-on-rails/src/base/full.ts +++ b/packages/react-on-rails/src/base/full.ts @@ -1,6 +1,6 @@ import { createBaseClientObject, type BaseClientObjectType } from './client.ts'; import type { ReactOnRailsInternal, RenderParams, RenderResult, ErrorOptions } from '../types/index.ts'; -import handleError from '../generateRenderingErrorMessage.ts'; +import handleError from '../handleError.ts'; import serverRenderReactComponent from '../serverRenderReactComponent.ts'; // Warn about bundle size when included in browser bundles diff --git a/packages/react-on-rails/src/handleError.ts b/packages/react-on-rails/src/handleError.ts new file mode 100644 index 0000000000..3419f71586 --- /dev/null +++ b/packages/react-on-rails/src/handleError.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { renderToString } from './ReactDOMServer.cts'; +import type { ErrorOptions } from './types/index.ts'; +import generateRenderingErrorMessage from './generateRenderingErrorMessage.ts'; + +const handleError = (options: ErrorOptions): string => { + const msg = generateRenderingErrorMessage(options); + const reactElement = React.createElement('pre', null, msg); + return renderToString(reactElement); +}; + +export default handleError; diff --git a/packages/react-on-rails/src/serverRenderReactComponent.ts b/packages/react-on-rails/src/serverRenderReactComponent.ts index b83cf27c8d..f47c4e9542 100644 --- a/packages/react-on-rails/src/serverRenderReactComponent.ts +++ b/packages/react-on-rails/src/serverRenderReactComponent.ts @@ -5,7 +5,7 @@ import type { ReactElement } from 'react'; import createReactOutput from './createReactOutput.ts'; import { isPromise, isServerRenderHash } from './isServerRenderResult.ts'; import buildConsoleReplay from './buildConsoleReplay.ts'; -import handleError from './generateRenderingErrorMessage.ts'; +import handleError from './handleError.ts'; import { renderToString } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import type { From 44437ff63e3436c86b1cd90ca4d6f8a75beab3fe Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 29 Oct 2025 21:39:42 +0300 Subject: [PATCH 12/13] make rsc and non-rsc test script choose tests according to the file extension --- packages/react-on-rails-pro/package.json | 4 ++-- ...SCSerialization.test.tsx => RSCSerialization.rsc.test.tsx} | 0 ...{ReactOnRailsRSC.test.tsx => ReactOnRailsRSC.rsc.test.tsx} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/react-on-rails-pro/tests/{RSCSerialization.test.tsx => RSCSerialization.rsc.test.tsx} (100%) rename packages/react-on-rails-pro/tests/{ReactOnRailsRSC.test.tsx => ReactOnRailsRSC.rsc.test.tsx} (100%) diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index d574a88673..c09a9ef077 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -8,8 +8,8 @@ "build-watch": "yarn run clean && yarn run tsc --watch", "clean": "rm -rf ./lib", "test": "yarn test:non-rsc && yarn test:rsc", - "test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(ReactOnRailsRSC).*\"", - "test:rsc": "NODE_CONDITIONS=react-server jest tests/ReactOnRailsRSC.test.tsx", + "test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(.rsc.test.).*\"", + "test:rsc": "NODE_CONDITIONS=react-server jest tests/*.rsc.test.*", "type-check": "yarn run tsc --noEmit --noErrorTruncation", "prepack": "nps build.prepack", "prepare": "nps build.prepack", diff --git a/packages/react-on-rails-pro/tests/RSCSerialization.test.tsx b/packages/react-on-rails-pro/tests/RSCSerialization.rsc.test.tsx similarity index 100% rename from packages/react-on-rails-pro/tests/RSCSerialization.test.tsx rename to packages/react-on-rails-pro/tests/RSCSerialization.rsc.test.tsx diff --git a/packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx b/packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.test.tsx similarity index 100% rename from packages/react-on-rails-pro/tests/ReactOnRailsRSC.test.tsx rename to packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.test.tsx From cf466b771ed22dd84fae241a7cf414129aab1cc1 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 30 Oct 2025 09:40:52 +0300 Subject: [PATCH 13/13] reuse react on rails handleError inside rorp handleError function --- packages/react-on-rails-pro/src/handleError.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react-on-rails-pro/src/handleError.ts b/packages/react-on-rails-pro/src/handleError.ts index 0b0435f8a9..5ea0a02e6d 100644 --- a/packages/react-on-rails-pro/src/handleError.ts +++ b/packages/react-on-rails-pro/src/handleError.ts @@ -1,13 +1,9 @@ -import * as React from 'react'; import { Readable } from 'stream'; import { ErrorOptions } from 'react-on-rails/types'; -import { renderToString } from 'react-on-rails/ReactDOMServer'; -import generateRenderingErrorMessage from 'react-on-rails/generateRenderingErrorMessage'; +import handleErrorAsString from 'react-on-rails/handleError'; const handleError = (options: ErrorOptions) => { - const msg = generateRenderingErrorMessage(options); - const reactElement = React.createElement('pre', null, msg); - const htmlString = renderToString(reactElement); + const htmlString = handleErrorAsString(options); return Readable.from([htmlString]); };