Repository navigation
defineHandler(middleware, handler) breaks page loaders and actions: 500 on GET, empty 204 after an action, validator types dropped #564
Description
Description
defineHandler(middleware..., handler) is documented for per-route middleware, and the loader and action guides say they use "the same defineHandler API as server routes". In routes/ it works. In a page's .server.ts it breaks both loaders and actions, even with a middleware that only calls next():
- A loader with middleware makes the page fail with a 500.
- An action with middleware returns an empty
204instead of the re-rendered page, and a validation failure returns the raw400JSON instead of the page with field errors. - The action's validator types are lost, so
useForm('/db?create', …)accepts any body.
Steps to reproduce
// pages/db.server.ts
import { defineHandler, defineMiddleware } from 'void';
import * as z from 'zod';
const passThrough = defineMiddleware(async (_c, next) => {
await next();
});
// Case A: loader with middleware
export const loader = defineHandler(passThrough, async () => {
return { notes: await getNotes() };
});
// Case B: action with middleware (test with a plain `defineHandler(async () => …)` loader, since case A fails first)
export const actions = {
create: defineHandler(
passThrough,
defineHandler.withValidator({
body: z.object({ title: z.string().min(1) }),
})(async (_c, { body }) => {
await createNote(body);
}),
),
};# Case A
curl -i http://localhost:5173/db
curl -i -H 'X-VoidPages: true' http://localhost:5173/db
# Case B
curl -i -X POST 'http://localhost:5173/db?create' -H 'Origin: http://localhost:5173' \
-H 'Content-Type: application/json' -H 'X-VoidPages: true' -d '{"title":"a"}'
curl -i -X POST 'http://localhost:5173/db?create' -H 'Origin: http://localhost:5173' \
-H 'Content-Type: application/json' -H 'X-VoidPages: true' -d '{"title":""}'Actual behavior
| Request | Without middleware | With passThrough |
|---|---|---|
GET /db (HTML or X-VoidPages) |
200, the page |
500 TypeError: Body has already been used. It can only be used once. |
Valid POST /db?create |
200, the page with fresh props |
204 No Content, empty body (the note is created) |
Invalid POST /db?create |
200, the page with errors: { title: '…' } |
400 with the raw {"error":"Validation failed","issues":[…]} |
Types: ResolveActionBody<'/db?create'> falls back to Record<string, unknown>. { title: 1 } type-checks and form.data.title is unknown.
Expected behavior
The middleware form behaves like the plain form: the loader renders the page, an action re-renders it with fresh props, a validation failure renders it with errors, and the validator's body type reaches void/routes.
Cause
packages/void/src/runtime/handler.ts (main @ 6ba3dbcc3a, lines 288–303): the composed handler stores the final result in c.res and returns c.res:
} else {
c.res = convertReturnValue(await handler(c));
}
// …
return c.res;Hono's c.res setter sets c.finalized = true, and Hono's compose only adopts a Response returned by a later handler while finalized === false. Page code in packages/void/src/pages/protocol.ts then reads that Response and returns a new page response, which Hono drops and replaces with c.res:
- Loader:
runLoader()→extractProps()readsc.res's JSON body, so Hono sends a body that was already read → 500. - Action that returns nothing:
c.resis the204fromconvertReturnValue(undefined), and that is what's sent. - Validation failure:
c.resiswithValidator's400. Since fix(pages): deferred props, action responses and dev rescan errors #517 it is read viaclone(), so it's sent as is. On 1.1.12 this was a 500 (This ReadableStream is disturbed).
In routes/ the returned Response is the final answer anyway, so nothing is lost there.
Types: the middleware overloads return TypedHandler<{}, …>, so the __validators of a withValidator handler passed last are dropped. #490 fixed __output for loaders, not the validators.
Possible fix
- Runtime: have the composed handler return the handler's raw result (or a middleware's short-circuit Response) instead of assigning it to
c.res, so page code gets the same value it gets without middleware. Middleware that editsc.resafternext()(e.g. Hono'setag()) should keep working inroutes/, so theconvertReturnValuestep may need to move to the route adapter. - Types: an overload that keeps the last handler's type, e.g.
defineHandler<V, R>(mw: MiddlewareFn, handler: TypedHandler<V, R>): TypedHandler<V, R>.
Workaround we use for now: a small withMiddleware(...middleware, handler) that runs the chain itself, returns the handler's result untouched, and is typed as the handler (H extends TypedHandler).
Related
InferPropsisunknownfor adefineHandlerloader with middleware #489 / fix(handler): infer output type for defineHandler with middleware #490: the loader output type with middleware (fixed in 1.2.0).InferPropsisunknownfor adefineHandlerloader with middleware #489 notes that the runtime already unwraps a loader's return value as props, but on 1.2.1 the page itself fails (case A).- fix(pages): deferred props, action responses and dev rescan errors #517: the
clone()change for 400 responses from actions.
Environment
- void 1.2.1, @void/react 1.2.1 (the action cases also reproduce on 1.1.12)
- hono 4.13.13, zod 4
- vite-plus 1.3.0, Node 22.23.3, macOS 26.6
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity