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). 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 diff --git a/packages/react-on-rails-pro/jest.config.js b/packages/react-on-rails-pro/jest.config.js index 10964867aa..747b7a50b0 100644 --- a/packages/react-on-rails-pro/jest.config.js +++ b/packages/react-on-rails-pro/jest.config.js @@ -28,7 +28,13 @@ 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(','), + } + : {}, // 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..c09a9ef077 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -7,7 +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", + "test": "yarn test:non-rsc && yarn test:rsc", + "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/src/RSCRoute.tsx b/packages/react-on-rails-pro/src/RSCRoute.tsx index 1c514b8cf0..84d2a4b34c 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 serialized 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/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..5ea0a02e6d --- /dev/null +++ b/packages/react-on-rails-pro/src/handleError.ts @@ -0,0 +1,10 @@ +import { Readable } from 'stream'; +import { ErrorOptions } from 'react-on-rails/types'; +import handleErrorAsString from 'react-on-rails/handleError'; + +const handleError = (options: ErrorOptions) => { + const htmlString = handleErrorAsString(options); + return Readable.from([htmlString]); +}; + +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..8286d3feb9 --- /dev/null +++ b/packages/react-on-rails-pro/src/handleErrorRSC.ts @@ -0,0 +1,13 @@ +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); + return renderToPipeableStream(new Error(msg), { + 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/RSCSerialization.rsc.test.tsx b/packages/react-on-rails-pro/tests/RSCSerialization.rsc.test.tsx new file mode 100644 index 0000000000..2d9362a9e7 --- /dev/null +++ b/packages/react-on-rails-pro/tests/RSCSerialization.rsc.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', + }), + ); +}); diff --git a/packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.test.tsx b/packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.test.tsx new file mode 100644 index 0000000000..3d8d5330a3 --- /dev/null +++ b/packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.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..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 @@ -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; 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 { diff --git a/script/convert b/script/convert index 74f66beb47..b68d7c26fa 100755 --- a/script/convert +++ b/script/convert @@ -40,9 +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", - '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 +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")