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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Fixed

- **Server-render error wrapping preserves original causes**: When server rendering catches a non-`Error` thrown value, React on Rails now wraps it with the original value attached as `cause`, making downstream debugging preserve more context. Fixes [Issue 1746](https://github.com/shakacode/react_on_rails/issues/1746). [PR 3230](https://github.com/shakacode/react_on_rails/pull/3230) by [justin808](https://github.com/justin808).
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ describe('serverRenderRSCReactComponent', () => {

if (expectedError) {
expect(onError).toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(new Error(expectedError));
const [emittedError] = onError.mock.calls[0];
expect(Object.prototype.toString.call(emittedError)).toBe('[object Error]');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using Object.prototype.toString.call here is intentionally defensive because the error may be created inside the VM context (a different JavaScript realm), making instanceof Error unreliable. The approach is correct — just leaving a note so it's clear this isn't accidental verbosity.

expect(emittedError.message).toBe(expectedError);
}

expectedContents.forEach((text) => {
Expand Down
31 changes: 30 additions & 1 deletion packages/react-on-rails/src/serverRenderUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,37 @@ export function createResultObject(
};
}

function isCrossRealmError(e: unknown): e is { message?: unknown } {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor edge case: if a cross-realm error has no string message (e.g. new Error() where the message property is somehow non-string), isCrossRealmError returns true but the first if doesn't match. Execution falls through to JSON.stringify(e), which returns "{}" for typical Error objects (their message/stack properties are non-enumerable). The result would be [object Error] from the ?? fallback — not wrong, but potentially confusing.

Not a blocker — the original value is still preserved as cause — just worth knowing this is the fallback path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This check correctly handles cross-realm errors but will also match any plain object where a user has set [Symbol.toStringTag] = 'Error'. For that object, stringifyThrownValue would then try to read .message as a string instead of JSON-serialising it, producing a misleading or empty message string.

Extremely unlikely in SSR in practice, but a belt-and-suspenders guard could be:

Suggested change
function isCrossRealmError(e: unknown): e is { message?: unknown } {
function isCrossRealmError(e: unknown): e is { message?: unknown } {
return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';
}

The explicit typeof e === 'object' && e !== null guard is redundant (toString handles those), but it makes the intent unmistakable and harmless.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The name isCrossRealmError is slightly misleading — Object.prototype.toString.call(new Error()) also returns '[object Error]' for same-realm errors. In practice this is harmless because convertToError returns same-realm Error instances early via instanceof, but a future reader might wonder why a same-realm new Error() passes this guard.

A name like isErrorLike or hasErrorTag would be more accurate:

Suggested change
function isCrossRealmError(e: unknown): e is { message?: unknown } {
function isErrorLike(e: unknown): e is { message?: unknown } {

return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Object.prototype.toString.call(e) reads Symbol.toStringTag, so any plain object with [Symbol.toStringTag]: 'Error' will pass this check and be treated as a cross-realm error. In practice that means such an object gets e.message (or '[object Error]') as the error message instead of a JSON representation, which could be surprising.

If this edge case is worth guarding, you could tighten the check:

Suggested change
return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';
function isCrossRealmError(e: unknown): e is { message?: unknown } {
if (typeof e !== 'object' || e === null) return false;
if (Symbol.toStringTag in e && (e as Record<symbol, unknown>)[Symbol.toStringTag] !== undefined) return false;
return Object.prototype.toString.call(e) === '[object Error]';
}

Alternatively, a comment acknowledging the limitation is fine since it's a very unlikely throw value in practice.

}

function stringifyThrownValue(e: unknown): string {
if (isCrossRealmError(e)) {
return typeof e.message === 'string' ? e.message : Object.prototype.toString.call(e);
}

if (typeof e === 'object' && e !== null) {
try {
// JSON.stringify can return undefined without throwing, for example when toJSON returns undefined.
return JSON.stringify(e) ?? Object.prototype.toString.call(e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The ?? here is intentionally correct over ||: JSON.stringify can return undefined (not just throw) when an object's toJSON() method returns undefined, and an empty string "" from toJSON: () => '' should be kept as-is. Worth a short comment since the || vs ?? distinction is subtle in this context.

Suggested change
return JSON.stringify(e) ?? Object.prototype.toString.call(e);
return JSON.stringify(e) ?? Object.prototype.toString.call(e); // ?? not ||: JSON.stringify can return undefined without throwing (e.g. custom toJSON)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment correctly calls out the toJSON-returning-undefined edge case, but it isn't covered by the test suite. Consider adding a test:

it('wraps objects whose toJSON returns undefined', () => {
  const obj = { toJSON: () => undefined };
  const error = convertToError(obj);
  expect(error.message).toBe('[object Object]');
  expect((error as ErrorWithCause).cause).toBe(obj);
});

} catch {
return Object.prototype.toString.call(e);
}
}

return String(e);
}

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

const message = stringifyThrownValue(e);
// tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cast is fine for now, but this can be replaced once the root tsconfig moves to "lib": ["dom", "es2022"]ErrorOptions with cause is included in the ES2022 type definitions. If that change lands later, this cast and the comment can be removed. Just flagging in case there's an appetite to bump the lib version in a follow-up.

const error = new Error(message) as Error & { cause?: unknown };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cast works, but a cleaner long-term option is to add a global type augmentation rather than casting at each use site. Alternatively, if there's appetite to bump lib to es2022 in tsconfig, Error.cause becomes natively typed. The comment explains the rationale well either way — just flagging it in case the tsconfig target is ever raised.

error.cause = e;
return error;
}

export function validateComponent(componentObj: RegisteredComponent, componentName: string) {
Expand Down
90 changes: 90 additions & 0 deletions packages/react-on-rails/tests/serverRenderUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Script } from 'node:vm';

import { convertToError } from '../src/serverRenderUtils.ts';

type ErrorWithCause = Error & { cause?: unknown };

describe('serverRenderUtils', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cast (error as Error & { cause?: unknown }) is repeated 7 times across the suite. A file-level type alias would reduce noise:

Suggested change
describe('serverRenderUtils', () => {
type ErrorWithCause = Error & { cause?: unknown };
describe('serverRenderUtils', () => {

Then each assertion becomes (error as ErrorWithCause).cause, which is easier to scan.

describe('convertToError', () => {
it('returns Error instances unchanged', () => {
const error = new Error('Already an error');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider adding a test for built-in Error subclasses (e.g. TypeError, RangeError). Since instanceof Error is true for all subclasses, they're returned unchanged — but an explicit test guards against any future refactor that might accidentally re-wrap them:

it('returns Error subclass instances unchanged', () => {
  const typeError = new TypeError('wrong type');
  expect(convertToError(typeError)).toBe(typeError);
});


expect(convertToError(error)).toBe(error);
});

it('wraps plain object thrown values with a readable message while preserving the original cause', () => {
const thrownValue = { message: 'plain object failure' };

const error = convertToError(thrownValue);

expect(error).toBeInstanceOf(Error);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This assertion locks in the fact that throwing a plain object yields the unhelpful message "[object Object]". That's pre-existing behaviour (the old code did String(e) too), but now that cause is available it might be worth considering a better message for objects, e.g. attempting JSON.stringify with a fallback:

// e.g.
function safeStringify(e: unknown): string {
  if (typeof e === 'object' && e !== null) {
    try { return JSON.stringify(e); } catch { /* circular */ }
  }
  return String(e);
}

Not required for this PR — the message is unchanged from before — but since the PR's stated goal is better downstream debugging, a more readable message would complement the cause improvement.

expect(error.message).toBe('{"message":"plain object failure"}');
expect((error as ErrorWithCause).cause).toBe(thrownValue);
});

it('wraps a thrown string with the string as the message and cause', () => {
const error = convertToError('something went wrong');

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('something went wrong');
expect((error as ErrorWithCause).cause).toBe('something went wrong');
});

it('wraps a thrown number', () => {
const error = convertToError(42);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('42');
expect((error as ErrorWithCause).cause).toBe(42);
});

it('wraps null thrown values', () => {
const error = convertToError(null);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('null');
expect((error as ErrorWithCause).cause).toBeNull();
});

it('wraps undefined thrown values', () => {
const error = convertToError(undefined);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('undefined');
expect((error as ErrorWithCause).cause).toBeUndefined();
});

it('wraps circular-reference objects without throwing', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;

const error = convertToError(circular);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('[object Object]');
expect((error as ErrorWithCause).cause).toBe(circular);
});

it('wraps errors thrown from another JavaScript realm with their original message', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test covers the happy-path of the cross-realm branch, but the branch inside isCrossRealmError for when .message is not a string (Object.prototype.toString.call(e) fallback in stringifyThrownValue) is untested. A companion case would close that gap:

it('wraps a cross-realm error whose message is non-string using the toString tag', () => {
  const thrownValue: unknown = new Script('const e = new Error(); e.message = 42; e').runInNewContext();

  const error = convertToError(thrownValue);

  expect(error).toBeInstanceOf(Error);
  expect(error.message).toBe('[object Error]');
  expect((error as ErrorWithCause).cause).toBe(thrownValue);
});

const thrownValue: unknown = new Script('new Error("cross-realm failure")').runInNewContext();

const error = convertToError(thrownValue);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('cross-realm failure');
expect((error as ErrorWithCause).cause).toBe(thrownValue);
});

it('wraps cross-realm errors with non-string messages using the error tag', () => {
const thrownValue: unknown = new Script(
'const error = new Error(); error.message = 42; error',
).runInNewContext();

const error = convertToError(thrownValue);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('[object Error]');
expect((error as ErrorWithCause).cause).toBe(thrownValue);
});
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The two existing cases are good, but a few more would meaningfully increase coverage of real-world throws:

Suggested change
});
it('wraps plain object thrown values with a readable message while preserving the original cause', () => {
const thrownValue = { message: 'plain object failure' };
const error = convertToError(thrownValue);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('{"message":"plain object failure"}');
expect((error as Error & { cause?: unknown }).cause).toBe(thrownValue);
});
it('wraps a thrown string with the string as the message and as cause', () => {
const error = convertToError('something went wrong');
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('something went wrong');
expect((error as Error & { cause?: unknown }).cause).toBe('something went wrong');
});
it('wraps a thrown number', () => {
const error = convertToError(42);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('42');
});
it('wraps a circular-reference object without throwing (exercises the catch branch)', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const error = convertToError(circular);
expect(error).toBeInstanceOf(Error);
// JSON.stringify throws on circular refs; falls back to [object Object]
expect(error.message).toBe('[object Object]');
expect((error as Error & { cause?: unknown }).cause).toBe(circular);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good suite overall. Two thrown values that are easy to miss and worth adding:

  • null — passes through String(null)"null" (not the object branch, since null !== null guard excludes it)
  • undefined — passes through String(undefined)"undefined"

Both are real-world cases (e.g. throw null in legacy code). A quick pair of tests would lock in the expected message and confirm cause is set correctly for each.

Loading