`defineHandler(middleware, handler)` breaks page loaders and actions: 500 on GET, empty 204 after an action, validator types dropped
Les mainteneurs répondent en général sous 1 jour
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Accessibilité débutants
- 55/100
- Type d'issue
- Bug
- Clarté
- Clairement spécifiée
- Activité
- Active
- Stack technique
- typescript
Piste de recherche
Read packages/void/src/runtime/handler.ts around lines 288–303 and trace how its composed handler returns responses; then follow runLoader() and extractProps() in packages/void/src/pages/protocol.ts. Reproduce the loader and action cases from the issue, including validation failure, and check that page responses render with fresh props or field errors and that validator types reach void/routes.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
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 #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
- #489 / #490: the loader output type with middleware (fixed in 1.2.0). #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).
- #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
- Langage dominant
- TypeScript
- Étoiles
- 580
- Forks
- 56
- Merge moyen
- 12 h 25 min
- PR mergées (30 j)
- 134
Préparer son environnement
Ce projet ne fournit ni conteneur de développement, ni Dockerfile, ni guide de contribution : l'installation est à votre charge. Commencez par son README, et consultez notre guide de la première contribution pour les étapes générales.
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de voidzero-dev/void
-
Difficulté 3/5 1-2 jours Accessibilité débutants 66/100
voidzero-dev/void#599 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 82/100
voidzero-dev/void#596 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 4/5 3-5 jours Accessibilité débutants 56/100
voidzero-dev/void#579 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 72/100
voidzero-dev/void#575 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 3/5 1-2 jours Accessibilité débutants 55/100
voidzero-dev/void#573 ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de voidzero-dev/void
Issues similaires
-
DRY stats column groupingsOuverteready
Difficulté 2/5 1-3 heures Accessibilité débutants 63/100
wheresrhys/totf#1214 ·
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 78/100
CodeChefVIT/ffcs#281 ·
Les mainteneurs répondent en général sous 2 jours
-
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 2/5 1-3 heures Accessibilité débutants 83/100
endora-commerce/endora-commerce#451 ·
Les mainteneurs répondent en général sous 1 jour
-
enhancement
Difficulté 2/5 1-3 heures Accessibilité débutants 65/100
vetro-protocol/vetro-monorepo#1007 ·
Les mainteneurs répondent en général sous 1 jour