Skip to content

fix: preserve causes when wrapping render errors#3230

Open
justin808 wants to merge 2 commits intomainfrom
codex/use-error-cause
Open

fix: preserve causes when wrapping render errors#3230
justin808 wants to merge 2 commits intomainfrom
codex/use-error-cause

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented Apr 30, 2026

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

Low Risk
Small, localized change to SSR error wrapping plus new unit tests; behavior only affects error reporting paths.

Overview
Fixes SSR error normalization so convertToError preserves the original non-Error thrown value via Error(..., { cause }), improving downstream debugging while leaving real Error instances untouched.

Adds a focused unit test for convertToError and documents the behavior change in CHANGELOG.md.

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Warning

Rate limit exceeded

@justin808 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 4 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e67952e-c91e-4692-98eb-f044733e57c9

📥 Commits

Reviewing files that changed from the base of the PR and between f25cde3 and e5d2867.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • packages/react-on-rails/src/serverRenderUtils.ts
  • packages/react-on-rails/tests/serverRenderUtils.test.ts
✨ Finishing Touches
🧪 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
Review rate limit: 0/8 reviews remaining, refill in 3 minutes and 4 seconds.

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

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 30, 2026

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
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

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
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

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%)

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