Skip to content

fix: preserve causes when wrapping render errors#3230

Merged
justin808 merged 7 commits into
mainfrom
codex/use-error-cause
May 5, 2026
Merged

fix: preserve causes when wrapping render errors#3230
justin808 merged 7 commits into
mainfrom
codex/use-error-cause

Conversation

@justin808

@justin808 justin808 commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

  • preserve the original thrown value as cause when server rendering wraps a non-Error value
  • add coverage for unchanged Error instances and wrapped non-Error values

Fixes #1746

Test plan

  • pnpm --filter react-on-rails test -- serverRenderUtils.test.ts
  • pnpm exec eslint packages/react-on-rails/src/serverRenderUtils.ts packages/react-on-rails/tests/serverRenderUtils.test.ts
  • pnpm run type-check
  • pnpm exec prettier --check packages/react-on-rails/src/serverRenderUtils.ts packages/react-on-rails/tests/serverRenderUtils.test.ts
  • git diff --check

Note

Medium Risk
Touches server-side render error handling, which could subtly change logged/returned error messages and how downstream code inspects errors, but is limited in scope and covered by new tests.

Overview
Improves SSR error wrapping to retain original throw context. convertToError now stringifies non-Error thrown values more defensively (including circular objects and cross-realm errors) and attaches the original thrown value as error.cause when wrapping.

Adds focused test coverage for these cases in serverRenderUtils.test.ts, and updates the Pro node-renderer RSC streaming test to assert emitted errors by message/type rather than strict new Error(...) equality. Also records the change in CHANGELOG.md.

Reviewed by Cursor Bugbot for commit 0edc319. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Improved server-render error handling: non-Error values thrown during server rendering are now wrapped in Error instances and the original thrown value is preserved as the error cause to aid debugging.
  • Tests

    • Added tests covering conversion of thrown values (native Errors, primitives, plain and circular objects, cross‑realm values) to ensure consistent wrapping and cause preservation.
  • Documentation

    • Updated changelog with the server-render error handling fix.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fea21a01-ea07-4248-a234-e93d8a37eb28

📥 Commits

Reviewing files that changed from the base of the PR and between 8f80a19 and 0edc319.

📒 Files selected for processing (1)
  • packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js

Walkthrough

Reworks server-render error handling: convertToError preserves native Error instances, wraps non-Error throwables into new Error objects with a derived message, and attaches the original thrown value as cause. Adds helpers for cross-realm/circular-safe message extraction, tests, and a changelog entry.

Changes

Server-render error wrapping

Layer / File(s) Summary
Helpers / Message Extraction
packages/react-on-rails/src/serverRenderUtils.ts
Adds isCrossRealmError and stringifyThrownValue to detect cross-realm Error-like objects and derive safe messages (prefer cross-realm message, then JSON.stringify with fallback to toString, else String).
Core Implementation
packages/react-on-rails/src/serverRenderUtils.ts
Reworks convertToError to return native Error unchanged; for non-Error values, create a new Error with the derived message and set error.cause = the original thrown value.
Tests
packages/react-on-rails/tests/serverRenderUtils.test.ts, packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js
Adds Jest tests verifying passthrough for real Error, wrapping behavior for objects/primitives/null/undefined, handling of circular objects, and cross-realm errors. Updates an assertion in expectStreamContent to validate emitted error instance and message rather than matching new Error(...).
Changelog
CHANGELOG.md
Adds an [Unreleased] → Fixed entry documenting that non-Error thrown values are wrapped and preserved as cause.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through thrown things, odd and wild,
I wrapped each tumble — careful, meek, and mild.
The message stays bright, the original stays near,
Cause tucked in pocket — debugging’s clear.
A joyful thump for errors now reconciled.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preserving causes when wrapping render errors, which directly corresponds to the primary objective of the PR.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #1746: uses Error.cause to preserve original thrown values, improves error messages, and includes comprehensive tests and documentation.
Out of Scope Changes check ✅ Passed All changes are within scope and directly address the linked issue: convertToError implementation, test coverage, test assertion updates, and changelog documentation for error cause preservation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/use-error-cause

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes convertToError in serverRenderUtils.ts so that when a non-Error value is thrown during server rendering, the original thrown value is preserved as the cause property on the wrapping Error, making it fully recoverable for debuggers and error reporters. A new test file validates both the passthrough path (existing Error instances) and the wrapping path (non-Error values with cause).

Confidence Score: 4/5

Safe to merge — the logic change is minimal, correct, and well-tested; only P2 style notes remain.

Only P2 findings: the TypeScript constructor cast is unconventional but functionally correct, and the '[object Object]' message behaviour is pre-existing. No logic or security issues introduced.

No files require special attention.

Important Files Changed

Filename Overview
packages/react-on-rails/src/serverRenderUtils.ts Expands convertToError to preserve the original non-Error thrown value as cause; uses an unusual but valid TypeScript cast to work around missing ErrorOptions type for older TS targets.
packages/react-on-rails/tests/serverRenderUtils.test.ts New test file covering both branches of convertToError — passthrough for Error instances and wrapping with cause for non-Error values; assertions are correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[convertToError called with unknown value] --> B{e instanceof Error?}
    B -- Yes --> C[Return e unchanged]
    B -- No --> D["new Error(String(e), { cause: e })"]
    D --> E[Error.message = String representation]
    D --> F[Error.cause = original thrown value]
Loading

Reviews (1): Last reviewed commit: "Add changelog for error cause preservati..." | Re-trigger Greptile

Comment on lines +32 to +33
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error;
return new ErrorWithCause(String(e), { cause: e });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unusual TypeScript type-cast for Error constructor

The ErrorWithCause cast is a workaround for TypeScript targets older than ES2022, where ErrorOptions (and the cause option) is not included in the built-in Error constructor type. If the project's tsconfig.json already targets ES2022 or higher, the cast is unnecessary and new Error(String(e), { cause: e }) works directly. If it must stay, an inline assertion is slightly more idiomatic:

return new Error(String(e), { cause: e } as { cause?: unknown });

Comment on lines +17 to +18
expect(error.message).toBe('[object Object]');
expect((error as Error & { cause?: unknown }).cause).toBe(thrownValue);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test assertion reveals misleading error message for object throws

The test asserts error.message equals '[object Object]', which confirms that throwing a plain object with a meaningful message property produces an unhelpful wrapper message. While this documents the current behaviour, it may be worth adding a comment that callers who need the original message should inspect error.cause rather than error.message. This is not a regression (the original new Error(String(e)) had the same limitation), but the new cause field makes the full value recoverable.

Comment on lines +31 to +33

const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error;
return new ErrorWithCause(String(e), { cause: 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 type cast is a workaround for lib: es2020 in tsconfig.json not including the ErrorOptions type (which gained the cause field in ES2022). Node 22 and all modern environments support it at runtime, but TypeScript's type-checker doesn't know about it under the current lib setting.

A cleaner fix would be to add "es2022.error" to the lib array in the root tsconfig.json (a targeted addition that avoids pulling in all of ES2022):

Suggested change
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error;
return new ErrorWithCause(String(e), { cause: e });
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error;
return new ErrorWithCause(String(e), { cause: e });

If you update tsconfig.json with "lib": ["dom", "es2020", "es2022.error"], both the cast here and the (error as Error & { cause?: unknown }) cast in the test can be removed, and TypeScript will validate the option natively.

Not a blocker — the cast is safe — but it leaves the type lie in place for the lifetime of the code.


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.

@claude

claude Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

Small, targeted fix to convertToError in serverRenderUtils.ts: non-Error thrown values are now wrapped with { cause: e }, preserving the original value for downstream debugging. Real Error instances pass through unchanged. Good unit tests are added for both branches.


What works well

  • The logic is correct — instanceof Error guard is unchanged, only the non-Error branch is modified.
  • New test file is well-structured and covers the two critical branches.
  • cause is a standard ES2022/Node 16.9+ feature; Node 22 (project's .nvmrc) supports it natively with no polyfill needed.
  • CHANGELOG entry follows the project format.

Issues / suggestions

1. Type-cast workaround vs. tsconfig lib update (inline comment on serverRenderUtils.ts:31-33)

const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error exists because "lib": ["dom", "es2020"] in the root tsconfig.json doesn't include ErrorOptions.cause (added in es2022.error). Adding "es2022.error" to the lib array would let TypeScript validate the option natively and would also remove the need for the (error as Error & { cause?: unknown }) cast in the test.

2. cause is not forwarded in createResultObject / renderingError

createResultObject serializes only error.message and error.stack into renderingError. The new cause will be available when the Error object is inspected locally (e.g., in a custom error reporter or logger attached to serverRenderReactComponent), but it won't appear in the JSON payload returned to the Rails view layer. This is probably intentional (cause can be any non-serializable value), but worth a brief comment in the code or this PR description so future readers know the scope.

3. [object Object] message (inline comment on serverRenderUtils.test.ts:16)

The test correctly documents that a plain object thrown value produces the message "[object Object]". That's pre-existing behaviour, but since the PR's goal is better debugging, a follow-up to use JSON.stringify with a fallback for object causes would make the error message itself more useful — the cause alone doesn't help if the message is opaque.

4. Minor: missing edge-case tests

The following thrown values are common in practice and quick to add:

  • throw 'a string' → message should equal the string, cause should be the string
  • throw nullString(null) = "null", cause = null

None of these change the implementation, but they document the contract and would catch a future regression if the branch is touched again.


Summary

The fix is correct and a clear improvement. The two actionable items before merge are:

  1. Decide whether to update tsconfig.json lib or keep the explicit cast (either is fine, but the lib update is cleaner).
  2. Consider whether cause intentionally not appearing in renderingError deserves a short comment.

The [object Object] message and extra test cases are nice-to-haves, not blockers.

@github-actions

github-actions Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.76 KB (0%)
react-on-rails/client bundled (brotli) 53.86 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.86 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.71 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.63 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.63 KB (0%)
registerServerComponent/client bundled (gzip) 127.53 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.53 KB (0%)
registerServerComponent/client bundled (brotli) 61.7 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.7 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.73 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.73 KB (0%)

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: This is a clean, well-scoped fix. The intent is correct — attaching the original thrown value as cause is the right approach for debuggability. A few minor issues worth addressing before merge.


Correctness

  • convertToError logic is correct: Error instances pass through unchanged, non-Error values get wrapped with cause.
  • The ErrorWithCause cast workaround is pragmatic given the es2020 lib target, and the comment explains why. An alternative would be bumping lib to es2022 in tsconfig.json, but the localized cast is acceptable.
  • If the runtime doesn't support Error({ cause }) (pre-ES2022 engines), the option is silently ignored — graceful degradation. ✓

Code Quality

stringifyThrownValue has a minor structural redundancy (see inline comments):

  • The if (json !== undefined) guard is correct for the rare toJSON() → undefined case, but the fallback Object.prototype.toString.call(e) appears twice (inside the try and after it), which is confusing. The post-try return is the one that actually handles the json === undefined path; the catch return is duplicated. Can be simplified with a nullish coalesce.

Test Coverage

The new tests cover the two main paths (passthrough of Error, wrapping of plain object), but there are several common throw patterns not covered:

  • throw "string message" — very common in legacy JS/Ruby-interop code
  • throw 42 / throw null / throw undefined — valid JS, seen in the wild
  • Circular reference objects — exercises the catch branch in stringifyThrownValue
  • Objects with toJSON() returning undefined — exercises the json === undefined branch

At minimum, a test for throw "string" (the second most common non-Error throw after plain objects) and one for a circular-reference object would close the most likely real-world gaps.


Summary

Area Status
Logic correctness
TypeScript types ✅ (workaround documented)
Changelog entry
Test: Error passthrough
Test: plain object
Test: primitive throws (string, number, null) ❌ missing
Test: circular reference object ❌ missing
Code simplification minor — duplicate fallback

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

return 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 Object.prototype.toString.call(e) fallback appears twice — once in the catch block and once here. Both paths end up doing the same thing. Consider simplifying with a nullish coalesce, which removes the duplication and makes the intent clearer:

Suggested change
return Object.prototype.toString.call(e);
if (typeof e === 'object' && e !== null) {
try {
return JSON.stringify(e) ?? Object.prototype.toString.call(e);
} catch {
return Object.prototype.toString.call(e);
}
}

expect((error as Error & { cause?: unknown }).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);
});
});


if (typeof e === 'object' && e !== null) {
try {
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)

};
}

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.


const message = stringifyThrownValue(e);
// tsconfig uses es2020 libs, which do not type ErrorOptions even though supported runtimes provide Error.cause.
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => 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.

The cast works, and the comment explains it well. An alternative that avoids the comment altogether is adding "ES2022" to the lib array in the root tsconfig.jsonErrorOptions was added in ES2022. That's a broader change, but it's purely additive for lib types and would let you write the constructor call directly. Happy to leave this as-is if keeping the tsconfig conservative is preferred.

expect((error as Error & { cause?: unknown }).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.

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.

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Review: fix: preserve causes when wrapping render errors

Overall: solid change, looks good to merge with minor nits.

This is a well-scoped improvement to SSR error normalization. The core fix — preserving the original thrown value via Error.cause instead of losing it to String(e) — is exactly right, and the helper decomposition (isCrossRealmErrorstringifyThrownValueconvertToError) makes the logic easy to follow.

What works well

  • Error.cause is the right mechanism. The fix uses the standard ES2022 { cause } options rather than attaching a custom property, so tooling that understands Error.cause (Node.js util.inspect, Sentry, etc.) will surface it automatically.
  • Cross-realm detection is correct. Using Object.prototype.toString.call(e) === '[object Error]' is the standard way to detect errors from other V8 contexts (VM sandboxes, iframes). instanceof fails across realms; this doesn't.
  • Circular-reference handling. The try/catch around JSON.stringify correctly catches the circular-reference TypeError and falls back to toString. The ?? vs || distinction is also correct (see inline note).
  • Tests are targeted and use node:vm for a real cross-realm scenario rather than mocking — that's the right level of fidelity.

Observations (see inline comments for specifics)

  1. isCrossRealmError fall-through edge case (line 27): if a cross-realm error somehow lacks a string message, the code falls through to JSON.stringify, which returns "{}" for most Error objects (non-enumerable props), then ??-falls-back to [object Error]. Not wrong, just worth knowing.
  2. ?? over || (line 38): subtle but intentional — a one-liner comment would prevent future readers from "simplifying" it to ||.
  3. ErrorWithCause cast (line 54): the workaround is clear and the comment explains it. Adding "ES2022" to the tsconfig lib array is a possible cleaner alternative (additive-only change), but no strong opinion either way.
  4. Test gaps (test file, line 60): null and undefined as thrown values aren't covered. Both are real-world cases and the current fallback behavior is correct, but explicit tests would lock it in.

Not a concern

The cause being JS-only (not forwarded through renderingError to Ruby) is expected and appropriate — createResultObject deliberately extracts only message and stack for the Ruby side.

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Review: fix: preserve causes when wrapping render errors

Overall: This is a clean, well-tested improvement. The logic is correct and the test suite covers the important edge cases. A few minor notes below.

What the PR does

convertToError previously used String(e) for all non-Error thrown values, producing unhelpful messages like "[object Object]" and discarding the original value entirely. The new version:

  • Keeps Error instances unchanged (unchanged behaviour)
  • Wraps everything else with { cause: e } so the original value is preserved in the error chain
  • Produces a more readable message: JSON for plain objects (with circular-reference guard), the .message string for cross-realm errors, and String(e) for primitives

Concerns / suggestions

1. isCrossRealmError false-positive: user-land objects with Symbol.toStringTag = 'Error'
Any plain object where someone has set [Symbol.toStringTag] = 'Error' will match Object.prototype.toString.call(e) === '[object Error]', causing stringifyThrownValue to treat it like a cross-realm Error and read .message instead of JSON-serialising it. The common workaround is to also check typeof e.constructor === 'function' or similar, though in practice this edge case is extremely unlikely during SSR.

2. Uncovered branch in isCrossRealmError
The else branch — Object.prototype.toString.call(e) — for when a cross-realm error's .message is not a string has no test. A one-liner covering thrownValue[Symbol.toStringTag] = 'Error'; delete thrownValue.message (or a VM error whose message is non-string) would close that gap.

3. Repeated type cast in tests
(error as Error & { cause?: unknown }).cause appears 7 times. A file-level type alias type ErrorWithCause = Error & { cause?: unknown } would reduce noise and make intent clearer.

4. Behaviour change for plain objects (documented, strictly better)
Old: String({ message: 'x' })"[object Object]". New: JSON.stringify({ message: 'x' })'{"message":"x"}'. Any downstream code that parsed the error message string and expected "[object Object]" would break, but that would be a very pathological pattern. The new behaviour is unambiguously better.

5. Error.cause compatibility
The workaround comment explains why the type cast is needed (lib: es2020). Error.cause is available since Node 16.9 / V8 9.3. If the project has a lower Node floor than 16.9 (no engines field is set in package.json), this would silently swallow the option at runtime. Worth adding a minimum Node version note or an explicit engines field if one isn't already elsewhere.

Verdict

No blocking issues. Items 1 and 5 are worth a quick check; items 2 and 3 are optional polish. The core fix is correct.

};
}

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.

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.


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

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.

expect((error as Error & { cause?: unknown }).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 message = stringifyThrownValue(e);
// tsconfig uses es2020 libs, which do not type ErrorOptions even though supported runtimes provide Error.cause.
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => 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.

Error.cause is available from Node 16.9 / V8 9.3. The project has no engines field in its package.json, so there's no guarantee this option is honoured at runtime. On Node ≤ 16.8 the second argument is silently ignored and error.cause would be undefined, making the feature a no-op rather than an error.

If the minimum supported Node version is already ≥ 16.9 (which is likely, given the overall toolchain), adding "engines": { "node": ">=16.9.0" } to package.json would make that explicit and protect against accidental regressions. Alternatively, a narrower fix here avoids the cast entirely:

Suggested change
const ErrorWithCause = Error as new (message?: string, options?: { cause?: unknown }) => Error;
const error = new Error(message);
// Error.cause available since Node 16.9 / ES2022; the tsconfig lib is es2020 so we assign directly.
(error as Error & { cause?: unknown }).cause = e;
return error;

}

function isCrossRealmError(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.

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 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);
});


const message = stringifyThrownValue(e);
// tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it.
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.

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR fixes #1746 by improving convertToError in serverRenderUtils.ts so that non-Error thrown values (strings, numbers, plain objects, circular objects, cross-realm errors) are wrapped with a readable message and have the original thrown value attached as error.cause. The test suite is new and well-structured.


Correctness ✅

The logic is sound:

  • instanceof Error fast-path returns the original unchanged — no regressions for the common case.
  • The isCrossRealmError guard correctly handles objects from Node VM contexts where instanceof fails across realm boundaries.
  • JSON.stringify with the ?? Object.prototype.toString.call(e) fallback cleanly handles circular references and toJSON-returning-undefined values.
  • Setting error.cause = e on the newly-created Error is the correct ES2022 pattern for preserving context.

Potential Issues

Symbol.toStringTag spoofing in isCrossRealmError (see inline comment on line 28)

Object.prototype.toString.call(e) respects Symbol.toStringTag. An object with { [Symbol.toStringTag]: 'Error', message: 123 } would pass isCrossRealmError and produce '[object Error]' as the message instead of the JSON representation. Very unlikely in practice, but worth being aware of.

Missing test for toJSON-returning-undefined (see inline comment on line 39)

The code comment calls out JSON.stringify(e) returning undefined when toJSON() returns undefined, but the test suite doesn't cover this edge case. Easy to add.


Breaking-change risk

The Cursor Bugbot note is accurate: the error message for non-Error thrown objects changes from String(e) (e.g. '[object Object]') to the JSON representation (e.g. '{"key":"value"}'). Any downstream log parsers or error handlers in user apps that match on that specific string would be affected. The change is intentionally better for debugging, but it's worth calling out in the changelog entry — the existing entry does this well.


Test Coverage ✅

The new serverRenderUtils.test.ts covers:

  • Passthrough for real Error instances
  • Plain objects, strings, numbers, null, undefined
  • Circular references
  • Cross-realm errors (normal and non-string .message)

All meaningful branches are exercised. Only the toJSON-returning-undefined case is missing (minor).


Code Quality

  • The helpers (isCrossRealmError, stringifyThrownValue) are small, focused, and well-named.
  • The TypeScript cast workaround (as Error & { cause?: unknown }) is a pragmatic fix — the comment explains why. See inline note on line 55 for a longer-term alternative.
  • The CHANGELOG entry is clear and links all the right things.

Verdict

Approve with minor suggestions. The core logic is correct, the test coverage is solid, and the change is a clear improvement. The three inline comments are all optional/minor — the toJSON test is the most actionable one.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (2)
packages/react-on-rails/src/serverRenderUtils.ts (2)

27-29: 💤 Low value

isCrossRealmError can produce false positives for objects with a custom Symbol.toStringTag

In ES2015+, Object.prototype.toString respects Symbol.toStringTag, so any plain object carrying [Symbol.toStringTag]: 'Error' will pass isCrossRealmError. When that happens, stringifyThrownValue extracts only .message (line 33) and ignores all other properties that JSON.stringify would have captured. The original value is still accessible via error.cause, so no information is permanently lost — but the error message will be less descriptive than the JSON-serialised alternative.

If tighter fidelity is needed, the guard can additionally verify the absence of Symbol.toStringTag on the instance itself:

🔎 Proposed tightening (optional)
 function isCrossRealmError(e: unknown): e is { message?: unknown } {
-  return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';
+  return (
+    typeof e === 'object' &&
+    e !== null &&
+    // Exclude objects that merely set Symbol.toStringTag to 'Error'
+    !Object.prototype.hasOwnProperty.call(e, Symbol.toStringTag) &&
+    Object.prototype.toString.call(e) === '[object Error]'
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-on-rails/src/serverRenderUtils.ts` around lines 27 - 29,
isCrossRealmError can misidentify plain objects with a custom Symbol.toStringTag
as Error, causing stringifyThrownValue to drop useful properties; update
isCrossRealmError (the type guard used by stringifyThrownValue) to also ensure
the target does not have an own Symbol.toStringTag property (use
Object.prototype.hasOwnProperty.call(e, Symbol.toStringTag) or equivalent) so
only genuine cross-realm Error instances pass the guard and other objects fall
back to JSON.stringify handling.

36-43: 💤 Low value

JSON.stringify silently returns '{}' for objects with only non-serializable properties

When e is an object whose properties are all non-serializable (e.g., { fn: () => {} }, { sym: Symbol() }), JSON.stringify(e) returns the string '{}'. Because '{}' is truthy, the ?? fallback never fires and the error message becomes the unhelpful literal '{}'.

The original value is preserved in error.cause, so this is not a data-loss problem, but it does make the error message misleading. A cheap guard catches the common case:

💡 Proposed improvement (optional)
   if (typeof e === 'object' && e !== null) {
     try {
-      return JSON.stringify(e) ?? Object.prototype.toString.call(e);
+      const json = JSON.stringify(e);
+      // JSON.stringify returns undefined when toJSON() returns undefined,
+      // and '{}' when all properties are non-serializable — both are unhelpful.
+      if (!json || json === '{}') {
+        return Object.prototype.toString.call(e);
+      }
+      return json;
     } catch {
       return Object.prototype.toString.call(e);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-on-rails/src/serverRenderUtils.ts` around lines 36 - 43, The
current logic returns JSON.stringify(e) without checking for the misleading '{}'
result for objects with only non-serializable properties; update the branch
handling object errors so that you capture the JSON.stringify(e) result into a
variable and, if it equals '{}' (or is undefined), fall back to
Object.prototype.toString.call(e) instead of returning '{}'. Locate the block
that calls JSON.stringify(e) (the branch testing typeof e === 'object' && e !==
null) and replace the direct JSON.stringify(e) ?? ... return with a guarded
flow: compute const serialized = JSON.stringify(e); if (!serialized ||
serialized === '{}') return Object.prototype.toString.call(e); else return
serialized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react-on-rails/src/serverRenderUtils.ts`:
- Around line 27-29: isCrossRealmError can misidentify plain objects with a
custom Symbol.toStringTag as Error, causing stringifyThrownValue to drop useful
properties; update isCrossRealmError (the type guard used by
stringifyThrownValue) to also ensure the target does not have an own
Symbol.toStringTag property (use Object.prototype.hasOwnProperty.call(e,
Symbol.toStringTag) or equivalent) so only genuine cross-realm Error instances
pass the guard and other objects fall back to JSON.stringify handling.
- Around line 36-43: The current logic returns JSON.stringify(e) without
checking for the misleading '{}' result for objects with only non-serializable
properties; update the branch handling object errors so that you capture the
JSON.stringify(e) result into a variable and, if it equals '{}' (or is
undefined), fall back to Object.prototype.toString.call(e) instead of returning
'{}'. Locate the block that calls JSON.stringify(e) (the branch testing typeof e
=== 'object' && e !== null) and replace the direct JSON.stringify(e) ?? ...
return with a guarded flow: compute const serialized = JSON.stringify(e); if
(!serialized || serialized === '{}') return Object.prototype.toString.call(e);
else return serialized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5b6381c4-9814-48fb-b50c-844008ded89d

📥 Commits

Reviewing files that changed from the base of the PR and between f13cda5 and 8f80a19.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/serverRenderUtils.ts
  • packages/react-on-rails/tests/serverRenderUtils.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/react-on-rails/tests/serverRenderUtils.test.ts

};
}

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.

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 } {

}

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.

describe('serverRenderUtils', () => {
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(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.

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This is a clean, focused fix for Issue 1746. The change preserves the original thrown value as cause when server rendering wraps a non-Error throwable, which meaningfully improves debuggability without breaking any existing callers (same-realm Error instances are returned unchanged, preserving their stack traces).

What works well

  • Backward compatible: e instanceof Error early-return ensures native errors pass through untouched with their original stack intact.
  • Defensive stringification: The try/catch around JSON.stringify plus the ?? fallback correctly handles circular references and custom toJSON returning undefined.
  • Cross-realm handling: Using Object.prototype.toString.call instead of instanceof to detect Error-like objects is the right approach for VM contexts.
  • Test coverage: Edge cases (circular, cross-realm, non-string message, primitives, null, undefined) are all explicitly tested.
  • RSC test improvement: Switching from toHaveBeenCalledWith(new Error(...)) to message/type assertions is strictly better — it survives cross-realm errors and errors with extra properties like cause.

Issues

Minor (naming clarity): isCrossRealmError also matches same-realm Error instances since Object.prototype.toString.call(new Error()) returns '[object Error]'. The name implies exclusivity that doesn't hold. isErrorLike or hasErrorTag would be more accurate — see inline comment on line 27.

Minor (test gap): No test for Error subclasses (TypeError, RangeError, etc.). These are handled correctly by instanceof Error, but an explicit regression test would protect against future refactors — see inline comment on the test file.

Informational (TypeScript cast): The as Error & { cause?: unknown } workaround is explained well by the inline comment. This cleans up naturally if the root tsconfig ever bumps lib to es2022.

Summary

The implementation is correct and the test coverage is solid. The two minor points above are suggestions, not blockers. Ready to merge once you decide whether to address the naming nit.

@justin808 justin808 merged commit 3157199 into main May 5, 2026
45 checks passed
@justin808 justin808 deleted the codex/use-error-cause branch May 5, 2026 06:48
justin808 added a commit that referenced this pull request May 8, 2026
* origin/main:
  docs: amend CHANGELOG entry for #3229 scope drift
  fix: validate selected JavaScript package manager
  fix: preserve causes when wrapping render errors (#3230)

# Conflicts:
#	CHANGELOG.md
justin808 added a commit that referenced this pull request May 10, 2026
…y-adapter

* origin/main:
  fix: support nested JavaScript package roots in doctor (#3220)
  docs: add Claude verify command (#3235)
  fix: serialize generated pack regeneration (#3231)
  docs: amend CHANGELOG entry for #3229 scope drift
  fix: validate selected JavaScript package manager
  fix: preserve causes when wrapping render errors (#3230)

# Conflicts:
#	react_on_rails/spec/lib/react_on_rails/doctor_spec.rb
justin808 added a commit that referenced this pull request May 11, 2026
…y-patch-DrhDk

* origin/main:
  docs: document legacy Webpacker compatibility shims (#3225)
  chore: sync address-review command and import prompt variant (#3271)
  feat: add REACT_ON_RAILS_BASE_PORT for concurrent worktree port management (#3142)
  feat: align renderer cache staging with bundle-hash layout (#3124)
  docs: document benchmark gate tuning plan (#3234)
  docs: document Gumroad RSC benchmark signal (#3233)
  docs: document migration proof artifact template (#3227)
  fix: support nested JavaScript package roots in doctor (#3220)
  docs: add Claude verify command (#3235)
  fix: serialize generated pack regeneration (#3231)
  docs: amend CHANGELOG entry for #3229 scope drift
  fix: validate selected JavaScript package manager
  fix: preserve causes when wrapping render errors (#3230)

# Conflicts:
#	CHANGELOG.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use Error causes

1 participant