From 3f21d45801e694729d99bcc782d81063665029b1 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Wed, 29 Jul 2026 19:01:52 +0200 Subject: [PATCH 1/4] fix: await server function error handlers --- .changeset/quiet-errors-wait.md | 5 +++++ apps/tests/src/server-fn-error.ts | 3 ++- packages/start/src/config/index.ts | 9 ++++----- packages/start/src/fns/error-handler.ts | 15 ++++++++------- packages/start/src/fns/handler.spec.ts | 21 +++++++++++++++++++++ packages/start/src/fns/handler.ts | 2 +- 6 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-errors-wait.md diff --git a/.changeset/quiet-errors-wait.md b/.changeset/quiet-errors-wait.md new file mode 100644 index 000000000..8e4849e79 --- /dev/null +++ b/.changeset/quiet-errors-wait.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Await asynchronous `serverFunctions.onError` handlers before serializing server function errors. diff --git a/apps/tests/src/server-fn-error.ts b/apps/tests/src/server-fn-error.ts index 3d3628dd5..ab10bf802 100644 --- a/apps/tests/src/server-fn-error.ts +++ b/apps/tests/src/server-fn-error.ts @@ -8,7 +8,8 @@ import type { ServerFunctionErrorHandler } from "@solidjs/start/server"; * Returning `undefined` sends whatever was thrown, which is what leaves the * other server-function error tests in this app seeing their own errors. */ -const onServerFunctionError: ServerFunctionErrorHandler = thrown => { +const onServerFunctionError: ServerFunctionErrorHandler = async thrown => { + await Promise.resolve(); if (thrown instanceof Error && thrown.message === "replace me") { return new Error("replaced by onError"); } diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 34e06da46..cb513f30d 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -157,8 +157,8 @@ export interface SolidStartOptions { * straight to its caller, and errors from API routes never reach it * either. * - * The export is called synchronously with the thrown value, and what it - * returns decides what the client sees: + * The export is called with the thrown value, and what it returns or + * resolves to decides what the client sees: * * - `undefined` (or `null`) sends what was thrown, unchanged. * - A `Response` is passed through unchanged, which keeps a thrown @@ -173,9 +173,8 @@ export interface SolidStartOptions { * properties, so an error meant to be safe to expose must not carry * internal detail. * - * The return value is not awaited, so make the export a plain function - * rather than an `async` one, and report failures with calls that do not - * need awaiting. + * The return value is awaited, so the export may be an `async` function, + * for example to flush a monitoring service before the response is sent. * * Type the export as `ServerFunctionErrorHandler` from * `@solidjs/start/server`. The module is bundled into the server only, so diff --git a/packages/start/src/fns/error-handler.ts b/packages/start/src/fns/error-handler.ts index 6ba8d2ba8..57d2530c3 100644 --- a/packages/start/src/fns/error-handler.ts +++ b/packages/start/src/fns/error-handler.ts @@ -5,14 +5,14 @@ import onServerFunctionError from "solid-start:server-fn-error-handler"; * it to type the default export of the module named by the * `serverFunctions.onError` option in `vite.config.ts`. * - * Called synchronously, and the value it returns is not awaited, so it cannot - * be an `async` function. + * May be asynchronous. A returned promise settles before the error is sent to + * the client. * * @param thrown The value the server function threw. This is a `Response` when * the server function threw control flow such as a `redirect()`. - * @returns `undefined` (or `null`) to send `thrown` unchanged, a `Response` to - * pass control flow through untouched, or any other value to send in place of - * `thrown`. + * @returns A value or promise resolving to `undefined` (or `null`) to send + * `thrown` unchanged, a `Response` to pass control flow through untouched, or + * any other value to send in place of `thrown`. * * @example * ```ts @@ -33,6 +33,7 @@ import onServerFunctionError from "solid-start:server-fn-error-handler"; */ export type ServerFunctionErrorHandler = (thrown: unknown) => unknown; -export function applyServerFunctionErrorHandler(thrown: unknown): unknown { - return onServerFunctionError?.(thrown) ?? thrown; +export async function applyServerFunctionErrorHandler(thrown: unknown): Promise { + const replacement = await onServerFunctionError?.(thrown); + return replacement ?? thrown; } diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index f4a44324d..6a33d852a 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -224,6 +224,17 @@ describe("the configured server function error handler", () => { expect(h3Event.res.headers.get("X-Error")).toBe("replaced"); }); + it("awaits an asynchronous replacement", async () => { + configuredErrorHandler.current = async () => { + await Promise.resolve(); + return new Error("replaced asynchronously"); + }; + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("replaced asynchronously"); + }); + it("treats a Response the handler returns as control flow", async () => { configuredErrorHandler.current = () => new Response(null, { status: 403 }); @@ -241,6 +252,16 @@ describe("the configured server function error handler", () => { expect(h3Event.res.headers.get("X-Error")).toBe("boom"); }); + it("keeps the original error when an asynchronous handler returns nothing", async () => { + configuredErrorHandler.current = async () => { + await Promise.resolve(); + }; + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("boom"); + }); + it("leaves the response untouched when no handler is configured", async () => { const h3Event = await callThrowing(new Error("boom")); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 135ce497c..1c6d994f4 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -127,7 +127,7 @@ export async function handleServerFunction(h3Event: H3Event) { } return serializeToJSONStream(result); } catch (x) { - x = applyServerFunctionErrorHandler(x); + x = await applyServerFunctionErrorHandler(x); if (x instanceof Response) { if (singleFlight && instance) { x = await handleSingleFlight(event, x); From 0f63c60b1c3a312dc32afeb38f8a6fca4ef7b048 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Wed, 29 Jul 2026 19:52:38 +0200 Subject: [PATCH 2/4] remove await Promise.resolve(); --- apps/tests/src/server-fn-error.ts | 1 - packages/start/src/fns/handler.spec.ts | 9 ++------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/tests/src/server-fn-error.ts b/apps/tests/src/server-fn-error.ts index ab10bf802..b7ba7ff0d 100644 --- a/apps/tests/src/server-fn-error.ts +++ b/apps/tests/src/server-fn-error.ts @@ -9,7 +9,6 @@ import type { ServerFunctionErrorHandler } from "@solidjs/start/server"; * other server-function error tests in this app seeing their own errors. */ const onServerFunctionError: ServerFunctionErrorHandler = async thrown => { - await Promise.resolve(); if (thrown instanceof Error && thrown.message === "replace me") { return new Error("replaced by onError"); } diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index 6a33d852a..f49129bed 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -225,10 +225,7 @@ describe("the configured server function error handler", () => { }); it("awaits an asynchronous replacement", async () => { - configuredErrorHandler.current = async () => { - await Promise.resolve(); - return new Error("replaced asynchronously"); - }; + configuredErrorHandler.current = async () => new Error("replaced asynchronously"); const h3Event = await callThrowing(new Error("boom")); @@ -253,9 +250,7 @@ describe("the configured server function error handler", () => { }); it("keeps the original error when an asynchronous handler returns nothing", async () => { - configuredErrorHandler.current = async () => { - await Promise.resolve(); - }; + configuredErrorHandler.current = async () => undefined; const h3Event = await callThrowing(new Error("boom")); From e91009852ed15722a0f5f8d8f6b245709fbd1ed6 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Wed, 29 Jul 2026 19:55:17 +0200 Subject: [PATCH 3/4] docs: clarify async error handler behavior --- packages/start/src/config/index.ts | 8 +++----- packages/start/src/fns/error-handler.ts | 10 +++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index cb513f30d..8effe6a75 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -157,8 +157,9 @@ export interface SolidStartOptions { * straight to its caller, and errors from API routes never reach it * either. * - * The export is called with the thrown value, and what it returns or - * resolves to decides what the client sees: + * The handler may be asynchronous. SolidStart awaits its return value + * before serializing the response, allowing a monitoring service to flush + * first. The resolved value decides what the client sees: * * - `undefined` (or `null`) sends what was thrown, unchanged. * - A `Response` is passed through unchanged, which keeps a thrown @@ -173,9 +174,6 @@ export interface SolidStartOptions { * properties, so an error meant to be safe to expose must not carry * internal detail. * - * The return value is awaited, so the export may be an `async` function, - * for example to flush a monitoring service before the response is sent. - * * Type the export as `ServerFunctionErrorHandler` from * `@solidjs/start/server`. The module is bundled into the server only, so * it may import server-only code such as a monitoring SDK. diff --git a/packages/start/src/fns/error-handler.ts b/packages/start/src/fns/error-handler.ts index 57d2530c3..e28f0219d 100644 --- a/packages/start/src/fns/error-handler.ts +++ b/packages/start/src/fns/error-handler.ts @@ -5,14 +5,14 @@ import onServerFunctionError from "solid-start:server-fn-error-handler"; * it to type the default export of the module named by the * `serverFunctions.onError` option in `vite.config.ts`. * - * May be asynchronous. A returned promise settles before the error is sent to - * the client. + * The handler may be asynchronous. SolidStart awaits its return value before + * sending the error to the client. * * @param thrown The value the server function threw. This is a `Response` when * the server function threw control flow such as a `redirect()`. - * @returns A value or promise resolving to `undefined` (or `null`) to send - * `thrown` unchanged, a `Response` to pass control flow through untouched, or - * any other value to send in place of `thrown`. + * @returns `undefined` (or `null`) to send `thrown` unchanged, a `Response` to + * preserve control flow, any other replacement value, or a promise resolving + * to any of those values. * * @example * ```ts From b608338cba47a3a49e94279590677a24013de81a Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Thu, 30 Jul 2026 03:18:55 +0200 Subject: [PATCH 4/4] fix: preserve errors when onError fails --- .changeset/quiet-errors-wait.md | 1 + packages/start/src/config/index.ts | 2 ++ packages/start/src/fns/error-handler.ts | 11 ++++++++--- packages/start/src/fns/handler.spec.ts | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.changeset/quiet-errors-wait.md b/.changeset/quiet-errors-wait.md index 8e4849e79..594247dce 100644 --- a/.changeset/quiet-errors-wait.md +++ b/.changeset/quiet-errors-wait.md @@ -3,3 +3,4 @@ --- Await asynchronous `serverFunctions.onError` handlers before serializing server function errors. +Preserve the original error if the handler throws or rejects. diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 8effe6a75..ea9a00428 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -166,6 +166,8 @@ export interface SolidStartOptions { * `redirect()` working. * - Any other value is sent in place of what was thrown, and the client * call rejects with it. + * - If the handler throws or rejects, what the server function threw is + * sent unchanged. * * Control flow reaches the export the same way errors do, so a handler * that replaces everything it sees turns redirects into errors. diff --git a/packages/start/src/fns/error-handler.ts b/packages/start/src/fns/error-handler.ts index e28f0219d..1164f0baa 100644 --- a/packages/start/src/fns/error-handler.ts +++ b/packages/start/src/fns/error-handler.ts @@ -6,7 +6,8 @@ import onServerFunctionError from "solid-start:server-fn-error-handler"; * `serverFunctions.onError` option in `vite.config.ts`. * * The handler may be asynchronous. SolidStart awaits its return value before - * sending the error to the client. + * sending the error to the client. If the handler throws or rejects, SolidStart + * sends the original thrown value instead. * * @param thrown The value the server function threw. This is a `Response` when * the server function threw control flow such as a `redirect()`. @@ -34,6 +35,10 @@ import onServerFunctionError from "solid-start:server-fn-error-handler"; export type ServerFunctionErrorHandler = (thrown: unknown) => unknown; export async function applyServerFunctionErrorHandler(thrown: unknown): Promise { - const replacement = await onServerFunctionError?.(thrown); - return replacement ?? thrown; + try { + const replacement = await onServerFunctionError?.(thrown); + return replacement ?? thrown; + } catch { + return thrown; + } } diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index f49129bed..58594bb4c 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -241,6 +241,15 @@ describe("the configured server function error handler", () => { expect(h3Event.res.headers.get("X-Error")).toBe("true"); }); + it("treats a Response resolved by an asynchronous handler as control flow", async () => { + configuredErrorHandler.current = async () => new Response(null, { status: 403 }); + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.status).toBe(403); + expect(h3Event.res.headers.get("X-Error")).toBe("true"); + }); + it("keeps the original error when the handler returns nothing", async () => { configuredErrorHandler.current = () => undefined; @@ -257,6 +266,16 @@ describe("the configured server function error handler", () => { expect(h3Event.res.headers.get("X-Error")).toBe("boom"); }); + it("keeps the original error when an asynchronous handler rejects", async () => { + configuredErrorHandler.current = async () => { + throw new Error("handler failed"); + }; + + const h3Event = await callThrowing(new Error("boom")); + + expect(h3Event.res.headers.get("X-Error")).toBe("boom"); + }); + it("leaves the response untouched when no handler is configured", async () => { const h3Event = await callThrowing(new Error("boom"));