Skip to content

fix(start-client-core): allow middleware to return custom error structures#7364

Open
Zelys-DFKH wants to merge 5 commits intoTanStack:mainfrom
Zelys-DFKH:fix-shallow-error-plugin-custom-props
Open

fix(start-client-core): allow middleware to return custom error structures#7364
Zelys-DFKH wants to merge 5 commits intoTanStack:mainfrom
Zelys-DFKH:fix-shallow-error-plugin-custom-props

Conversation

@Zelys-DFKH
Copy link
Copy Markdown
Contributor

@Zelys-DFKH Zelys-DFKH commented May 8, 2026

Fixes #7238

Problem

Middleware that wanted to return structured error responses from catch blocks would have those errors thrown immediately to the client, preventing global error classification middleware from working:

// Before: result.error gets thrown, { success: false } is lost
try {
  await next()
} catch (e) {
  return { success: false, error: { code: 'BILLING_ERROR', message: e.message } }
}

The middleware protocol was using .error property for two incompatible purposes:

  1. Framework errors: Thrown exceptions from middleware, validation failures (instances of Error)
  2. Application errors: Custom error structures returned by middleware (plain objects)

This collision meant middleware couldn't distinguish between "I'm returning an error as application data" and "this is a framework-level failure that should be thrown."

Solution

Use JavaScript's type system to distinguish errors:

  • Only throw `Error` instances (actual exceptions)
  • Pass through non-Error values in `result.error` as application data

This allows middleware to return custom error structures while preserving proper error propagation for real framework errors.

// After: Custom error structure is returned intact
try {
  await next()
} catch (e) {
  return { success: false, error: { code: 'BILLING_ERROR', message: e.message } }
}
// Client receives: { success: false, error: { code: 'BILLING_ERROR', ... } }

Changes

  • Line 165: `if (result.error)` → `if (result.error instanceof Error)`
  • Line 305: `if (result.error)` → `if (result.error instanceof Error)`

Breaking Change (Minor)

Middleware that throw non-Error values will now have those values captured in `result.error` instead of being thrown to the client.

Impact: Only affects middleware using non-standard error throwing patterns. Best practice is to throw `Error` instances.

Summary by CodeRabbit

  • Bug Fixes
    • Middleware/fetch error handling now preserves non-exception error payloads and returns them for downstream handling instead of throwing.
    • True exceptions plus redirect and not-found signals continue to be raised to preserve existing control flow.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea605fd3-a9fd-4953-9456-8920d175bd10

📥 Commits

Reviewing files that changed from the base of the PR and between fe3a3ae and ffdd580.

📒 Files selected for processing (1)
  • packages/start-client-core/src/createServerFn.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/start-client-core/src/createServerFn.ts

📝 Walkthrough

Walkthrough

The change refines middleware error propagation: imports isNotFound, and updates unwinding so result.error is thrown only for redirects, not-found sentinels, or Error instances; other non-Error error payloads are returned as resolved values (preferring result.result when present).

Changes

Middleware Error Propagation

Layer / File(s) Summary
Contracts / Import
packages/start-client-core/src/createServerFn.ts
Adds isNotFound import so code can distinguish not-found sentinel values from arbitrary truthy .error payloads.
Client fetcher result handling
packages/start-client-core/src/createServerFn.ts
Top-level client fetcher still throws redirects but now throws other errors only when they are Error instances or not-found; otherwise returns result.result ?? result.error.
userNext wrapper error handling
packages/start-client-core/src/createServerFn.ts
userNext inside executeMiddleware now throws for redirects, not-found, or Error instances; otherwise returns result.result ?? result.error to preserve middleware-returned payloads.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Fetcher
  participant executeMiddleware
  participant Middleware
  Client->>Fetcher: call server function
  Fetcher->>executeMiddleware: invoke middleware chain
  executeMiddleware->>Middleware: run middleware -> returns { result?, error? }
  Middleware->>executeMiddleware: returns result object
  executeMiddleware->>executeMiddleware: if isRedirect(error) or error instanceof Error or isNotFound(error) -> throw
  executeMiddleware->>Fetcher: else return result.result ?? result.error
  Fetcher->>Fetcher: if redirect -> throw redirect
  Fetcher->>Client: resolved value or thrown redirect/Error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop through middleware, light on my paws,
I leave Error throws to the system's laws,
When handlers return payloads not meant to break,
I carry them gently — no promise to flake.
Client gets the value; the rabbit bakes a cake.

🚥 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 describes the main change: allowing middleware to return custom error structures instead of throwing them.
Linked Issues check ✅ Passed The PR directly addresses issue #7238 by fixing middleware error handling to preserve application-level error objects returned by middleware instead of throwing them.
Out of Scope Changes check ✅ Passed All changes are focused on the middleware error handling mechanism in createServerFn.ts, directly addressing the linked issue without introducing unrelated modifications.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud Bot commented May 8, 2026

View your CI Pipeline Execution ↗ for commit c4a563d

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 9m 47s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 53s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-08 19:28:17 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Bundle Size Benchmarks

  • Commit: 4eed408f127b
  • Measured at: 2026-05-08T19:19:35.764Z
  • Baseline source: history:35e88f04996d
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Initial gzip Raw Brotli Trend
react-router.minimal 87.29 KiB +139 B (+0.16%) 87.15 KiB 274.07 KiB 75.81 KiB ▁▁▁▁▁▁▁▁▁▁▁█
react-router.full 90.82 KiB +141 B (+0.15%) 90.68 KiB 285.58 KiB 78.82 KiB ▁▁▁▁▁▁▁▁▁▁▁█
solid-router.minimal 35.51 KiB +126 B (+0.35%) 35.38 KiB 106.36 KiB 31.91 KiB ▁▁▁▁▁▁▁▁▁▁▁█
solid-router.full 40.23 KiB +127 B (+0.31%) 40.10 KiB 120.58 KiB 36.14 KiB ▁▁▁▁▁▁▁▁▁▁▁█
vue-router.minimal 53.28 KiB +131 B (+0.24%) 53.15 KiB 151.51 KiB 47.83 KiB ▁▁▁▁▁▁▁▁▁▁▁█
vue-router.full 58.41 KiB +133 B (+0.22%) 58.28 KiB 167.68 KiB 52.30 KiB ▁▁▁▁▁▁▁▁▁▁▁█
react-start.minimal 101.97 KiB +141 B (+0.14%) 101.84 KiB 322.51 KiB 88.13 KiB ▁▁▁▁▁▁▁▁▁▁▃█
react-start.full 105.43 KiB +159 B (+0.15%) 105.29 KiB 332.95 KiB 91.10 KiB ▁▁▁▁▁▁▁▁▁▁▃█
react-start.rsbuild.minimal 99.60 KiB +174 B (+0.17%) 99.43 KiB 316.97 KiB 85.65 KiB ▁▁▁▁▁▁▁▁▁▁▄█
react-start.rsbuild.full 102.91 KiB +194 B (+0.18%) 102.74 KiB 327.51 KiB 88.46 KiB ▁▁▁▁▁▁▁▁▁▁▃█
solid-start.minimal 49.61 KiB +131 B (+0.26%) 49.48 KiB 152.48 KiB 43.79 KiB ▁▁▁▁▁▁▁▁▁▁▄█
solid-start.full 55.42 KiB +157 B (+0.28%) 55.30 KiB 169.49 KiB 48.70 KiB ▁▁▁▁▁▁▁▁▁▁▃█

Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 8, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7364

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7364

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@7364

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7364

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7364

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7364

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7364

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7364

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7364

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7364

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@7364

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7364

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7364

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7364

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7364

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7364

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7364

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7364

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7364

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7364

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7364

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7364

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7364

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7364

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7364

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7364

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7364

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7364

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7364

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7364

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7364

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7364

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7364

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7364

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7364

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7364

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7364

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7364

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7364

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7364

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7364

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7364

commit: ffdd580

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/start-client-core/src/createServerFn.ts (1)

160-166: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Non-Error values in result.error are silently swallowed at the client boundary

After the fix, the client handler path is:

  1. parseRedirect(result.error) — handles redirect objects ✓
  2. if (result.error instanceof Error) throw result.error — rethrows actual errors ✓
  3. return result.result — returns the resolved value

The problem: when result.error is a non-Error truthy value (e.g. { code: 'auth', success: false }) and result.result is undefined, the caller silently receives undefined. The structured error is discarded without any signal.

This is reachable in practice: __executeServer explicitly ships d.error back to the client (line 202), so a non-Error thrown on the server (captured at the catch block, line 344–348) travels through the HTTP layer and arrives at the client middleware chain, only to be dropped at line 165–166.

Consider surfacing non-Error, non-redirect values through result.result so callers can observe them:

💡 Suggested approach
  const redirect = parseRedirect(result.error)
  if (redirect) {
    throw redirect
  }

  if (result.error instanceof Error) throw result.error
- return result.result
+ // Non-Error, non-redirect values in result.error are application-level
+ // error payloads; surface them as the resolved value when no result is present.
+ return result.result !== undefined ? result.result : result.error === undefined ? result.result : result.error

Or, more readably:

  if (result.error instanceof Error) throw result.error
- return result.result
+ return result.result ?? (result.error !== undefined ? result.error : undefined)

If the intent is that middleware must place structured responses in result.result and never in result.error, a comment documenting that invariant here would prevent future confusion:

  if (result.error instanceof Error) throw result.error
+ // Non-Error values in result.error are intentionally not forwarded to the caller;
+ // middleware must place structured responses in `result.result`.
  return result.result
🤖 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/start-client-core/src/createServerFn.ts` around lines 160 - 166, The
client-side handler in createServerFn.ts is dropping truthy non-Error errors:
after parseRedirect(result.error) and the instanceof Error check, detect when
result.error is a non-Error, non-redirect value (e.g., an object like
{code:'auth'}) and surface it via the normal success path instead of swallowing
it — e.g., assign that value into result.result (or return it directly) so
callers receive the structured payload; update the branch around
parseRedirect/result.error/result.result to return non-Error errors as the
result, and add a short comment documenting that invariant for future readers.
🤖 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.

Outside diff comments:
In `@packages/start-client-core/src/createServerFn.ts`:
- Around line 160-166: The client-side handler in createServerFn.ts is dropping
truthy non-Error errors: after parseRedirect(result.error) and the instanceof
Error check, detect when result.error is a non-Error, non-redirect value (e.g.,
an object like {code:'auth'}) and surface it via the normal success path instead
of swallowing it — e.g., assign that value into result.result (or return it
directly) so callers receive the structured payload; update the branch around
parseRedirect/result.error/result.result to return non-Error errors as the
result, and add a short comment documenting that invariant for future readers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec769bbe-644c-4188-8ce5-ea14fa53f787

📥 Commits

Reviewing files that changed from the base of the PR and between 4eed408 and e6214af.

📒 Files selected for processing (1)
  • packages/start-client-core/src/createServerFn.ts

…tures from catch blocks

Middleware that wanted to return structured error responses from catch blocks
would have those errors thrown immediately to the client. The middleware protocol
was using `.error` property for two incompatible purposes: framework errors
(Error instances) and application error data (plain objects).

This fix uses JavaScript's type system to distinguish them: only Error instances
are thrown, allowing middleware to return custom error structures while preserving
proper error propagation for real framework errors.

Fixes TanStack#7238

BREAKING CHANGE: Middleware that throw non-Error values will now have those
values captured in result.error instead of being thrown to the client. Only
affects non-standard code patterns; best practice is to throw Error instances.
@Zelys-DFKH Zelys-DFKH force-pushed the fix-shallow-error-plugin-custom-props branch from e6214af to 1abc348 Compare May 8, 2026 00:47
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 8, 2026

Merging this PR will not alter performance

✅ 5 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing Zelys-DFKH:fix-shallow-error-plugin-custom-props (ffdd580) with main (a04d5e4)2

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

  2. No successful run was found on main (4eed408) during the generation of this report, so a04d5e4 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

nx-cloud[bot]

This comment was marked as outdated.

The ?? operator treats null as a value, not nullish, so null ?? undefined
returns null. However, we need to distinguish between:
1. result.result explicitly set to null (should return null)
2. result.result undefined (should fallback to result.error for custom error payloads)

Change to: result.result !== undefined ? result.result : result.error

This preserves null while still supporting middleware error payloads.

Fixes TanStack#7364
Copy link
Copy Markdown
Contributor

@nx-cloud nx-cloud Bot left a comment

Choose a reason for hiding this comment

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

Important

At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.

Nx Cloud is proposing a fix for your failed CI:

We extended the instanceof Error guards at lines 165 and 312 of createServerFn.ts to also re-throw TanStack Router's redirect() and notFound() framework signals, which are not Error instances. This fixes the failing SSR and client-navigation tests by ensuring those signals propagate to the router instead of being silently returned as application data.

Tip

We verified this fix by re-running tanstack-react-start-e2e-rsc:test:e2e--vite-ssr--shard-5-of-6.

Suggested Fix changes
diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts
index ba9f7920..7a5a8e98 100644
--- a/packages/start-client-core/src/createServerFn.ts
+++ b/packages/start-client-core/src/createServerFn.ts
@@ -1,6 +1,6 @@
 import { mergeHeaders } from '@tanstack/router-core/ssr/client'
 
-import { isRedirect, parseRedirect } from '@tanstack/router-core'
+import { isNotFound, isRedirect, parseRedirect } from '@tanstack/router-core'
 import { TSS_SERVER_FUNCTION_FACTORY } from './constants'
 import { getStartOptions } from './getStartOptions'
 import { getStartContextServerOnly } from './getStartContextServerOnly'
@@ -162,7 +162,12 @@ export const createServerFn: CreateServerFn<Register> = (options, __opts) => {
             throw redirect
           }
 
-          if (result.error instanceof Error) throw result.error
+          if (
+            result.error instanceof Error ||
+            isRedirect(result.error) ||
+            isNotFound(result.error)
+          )
+            throw result.error
           // Non-Error values in result.error are application-level error payloads;
           // return them as the resolved value when no explicit result is present.
           return result.result !== undefined ? result.result : result.error
@@ -304,7 +309,11 @@ export async function executeMiddleware(
 
           const result = await callNextMiddleware(nextCtx)
 
-          if (result.error instanceof Error) {
+          if (
+            result.error instanceof Error ||
+            isRedirect(result.error) ||
+            isNotFound(result.error)
+          ) {
             throw result.error
           }
 

Because this branch comes from a fork, it is not possible for us to apply fixes directly, but you can apply the changes locally using the available options below.

Apply changes locally with:

npx nx-cloud apply-locally at1I-bhFr

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

Zelys-DFKH and others added 3 commits May 8, 2026 13:05
When unwinding middleware, redirect() and notFound() framework signals must be
re-thrown to reach the router—they are not Error instances. The previous
instanceof Error guard would silently return them as application data, breaking
SSR and client-navigation flow detection.

Extended both error-handling locations (client fetcher and middleware executor)
to also check isRedirect() and isNotFound(), ensuring framework signals propagate
correctly while custom error payloads continue to be returned as resolved values.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Middleware error handling: returning custom values from catch blocks doesn't reach the client

1 participant