Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/react-on-rails-pro/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(','),
}
: {},
Comment on lines +33 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Address the unresolved past review comment and handle edge cases.

The past review comment identified that the current default breaks RSC tests and causes pipeline failures. Additionally, the current implementation doesn't handle edge cases:

  • Empty string: "".split(',') returns [""] instead of an empty array
  • Whitespace: values are not trimmed after splitting

Apply this diff to address both the default and edge cases:

-  testEnvironmentOptions: process.env.NODE_CONDITIONS
-    ? {
-        customExportConditions: process.env.NODE_CONDITIONS.split(','),
-      }
-    : {},
+  testEnvironmentOptions: {
+    customExportConditions: process.env.NODE_CONDITIONS
+      ? process.env.NODE_CONDITIONS.split(',').map(s => s.trim()).filter(Boolean)
+      : ['react-server'],
+  },

This ensures:

  • RSC tests work correctly by defaulting to ['react-server']
  • Empty strings are filtered out
  • Whitespace is trimmed from condition names
🤖 Prompt for AI Agents
In packages/react-on-rails-pro/jest.config.js around lines 31 to 35, the current
testEnvironmentOptions logic breaks RSC tests and doesn't handle edge cases;
change it so when process.env.NODE_CONDITIONS is unset you default to
['react-server'], and when it is set split on ',' then map each entry to trimmed
string and filter out empty strings (so "" and whitespace-only entries are
removed) before assigning to customExportConditions. Ensure the final value is
an array of non-empty trimmed strings.

// Set root directory to current package
rootDir: '.',
};
4 changes: 3 additions & 1 deletion packages/react-on-rails-pro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion packages/react-on-rails-pro/src/RSCRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ export type RSCRouteProps = {

const PromiseWrapper = ({ promise }: { promise: Promise<React.ReactNode> }) => {
// 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 => {
Expand Down
9 changes: 4 additions & 5 deletions packages/react-on-rails-pro/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,7 +51,7 @@ const streamRenderRSCComponent = (
isShellReady: true,
};

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

const reportError = (error: Error) => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -99,7 +98,7 @@ const streamRenderRSCComponent = (

ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
try {
return streamServerRenderedComponent(options, streamRenderRSCComponent);
return streamServerRenderedComponent(options, streamRenderRSCComponent, handleError);
} finally {
console.history = [];
}
Expand Down
10 changes: 10 additions & 0 deletions packages/react-on-rails-pro/src/handleError.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions packages/react-on-rails-pro/src/handleErrorRSC.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -102,6 +101,6 @@ const streamRenderReactComponent = (
};

const streamServerRenderedReactComponent = (options: RenderParams): Readable =>
streamServerRenderedComponent(options, streamRenderReactComponent);
streamServerRenderedComponent(options, streamRenderReactComponent, handleError);

export default streamServerRenderedReactComponent;
10 changes: 5 additions & 5 deletions packages/react-on-rails-pro/src/streamingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -179,6 +179,7 @@ type StreamRenderer<T, P extends RenderParams> = (
export const streamServerRenderedComponent = <T, P extends RenderParams>(
options: P,
renderStrategy: StreamRenderer<T, P>,
handleError: (options: ErrorOptions) => PipeableOrReadableStream,
): T => {
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;

Expand Down Expand Up @@ -233,7 +234,7 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(

return renderStrategy(reactRenderingResult, optionsWithStreamingCapabilities, streamingTrackers);
} catch (e) {
const { readableStream, writeChunk, emitError, endStream } = transformRenderStreamChunksToResultObject({
const { readableStream, pipeToTransform, emitError } = transformRenderStreamChunksToResultObject({
hasErrors: true,
isShellReady: false,
result: null,
Expand All @@ -243,9 +244,8 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
}

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;
}
};
44 changes: 44 additions & 0 deletions packages/react-on-rails-pro/tests/RSCSerialization.rsc.test.tsx
Original file line number Diff line number Diff line change
@@ -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',
}),
);
});
13 changes: 13 additions & 0 deletions packages/react-on-rails-pro/tests/ReactOnRailsRSC.rsc.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
8 changes: 6 additions & 2 deletions packages/react-on-rails/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions packages/react-on-rails/src/base/full.rsc.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createBaseClientObject>[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');
},
};
Comment on lines +15 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix type signature mismatch in stub functions.

The stub implementations don't match the expected signatures from ReactOnRailsFullSpecificFunctions. While the functions throw errors immediately, they must still satisfy the type contract to maintain type safety.

Apply this diff to correct the function signatures:

   const reactOnRailsFullSpecificFunctions: ReactOnRailsFullSpecificFunctions = {
-    handleError() {
+    handleError(options) {
+      void options; // Mark as used
       throw new Error('"handleError" function is not supported in RSC bundle');
     },
 
-    serverRenderReactComponent() {
+    serverRenderReactComponent(options) {
+      void options; // Mark as used
       throw new Error('"serverRenderReactComponent" function is not supported in RSC bundle');
     },
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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');
},
};
const reactOnRailsFullSpecificFunctions: ReactOnRailsFullSpecificFunctions = {
handleError(options) {
void options; // Mark as used
throw new Error('"handleError" function is not supported in RSC bundle');
},
serverRenderReactComponent(options) {
void options; // Mark as used
throw new Error('"serverRenderReactComponent" function is not supported in RSC bundle');
},
};
🤖 Prompt for AI Agents
In packages/react-on-rails/src/base/full.rsc.ts around lines 15 to 23, the stub
functions throw errors but do not match the parameter and return type signatures
defined by ReactOnRailsFullSpecificFunctions; update each stub to use the exact
parameter list and return type declared in the ReactOnRailsFullSpecificFunctions
interface (e.g. add the same parameters and return type annotations) and keep
the immediate throw inside the body so the implementation still throws an Error
indicating the function isn't supported in the 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;
}
4 changes: 2 additions & 2 deletions packages/react-on-rails/src/base/full.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'
>;
Expand Down
12 changes: 2 additions & 10 deletions packages/react-on-rails/src/generateRenderingErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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!');
Expand All @@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return undefined instead of the string 'undefined'.

Line 63 returns the string literal 'undefined', which is incorrect. For client-side rendering, this should return undefined (the value, not a string).

Apply this diff:

-  return 'undefined';
+  return undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return 'undefined';
return undefined;
🤖 Prompt for AI Agents
In packages/react-on-rails/src/generateRenderingErrorMessage.ts around line 63,
the function currently returns the string literal 'undefined' but should return
the JavaScript undefined value; replace the string return with a bare undefined
return (i.e., return undefined) so client-side rendering receives the correct
undefined value.

};

export default handleError;
export default generateRenderingErrorMessage;
12 changes: 12 additions & 0 deletions packages/react-on-rails/src/handleError.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading