Describe the bug
Whether a 3xx status chosen by the author reaches the caller depends on three things that should not matter: whether the envelope was returned or thrown, whether the caller was scripted, and whether the value happened to be empty.
RFC 10 promises this flatly — "The handler forwards the envelope's headers and status" — and for every other status it holds. For 3xx it does not:
| what the function does |
scripted caller |
unscripted caller |
return respond(undefined, { status: 302, headers: { location } }) |
200 + Location |
200 + Location |
throw respond(undefined, { status: 302, headers: { location } }) |
200 + Location |
302 |
return respond({ a: 1 }, { status: 302, headers: { location } }) |
200 + Location |
302 |
return respond(undefined, { status: 304 }) |
200 |
200 |
throw respond(undefined, { status: 304 }) |
200 |
304 |
throw redirect("/elsewhere") |
200 + Location |
302 |
return respond({ a: 1 }, { status: 201 }) |
201 |
201 |
Three things fall out of that table.
A returned redirect is dropped for everyone. The two paths guard the same thing differently (packages/web/server-functions/src/server.ts) — the thrown path lets an unscripted caller through, the returned path does not:
// returned envelope
if (response && response.status && (response.status < 300 || response.status >= 400)) {
status = response.status;
}
// thrown envelope
if (response && response.status && (!instance || response.status < 300 || response.status >= 400)) {
status = response.status;
}
So a no-JS form post answering return respond(undefined, { status: 302, headers: { location } }) renders a 200 and never navigates.
Whether the value is empty decides the outcome. An unscripted caller does get the 302 from a returned envelope if the value is non-empty, through an earlier passthrough (if (!instance && !handleNoJS && response && response.body) return response;). Same intent, same call shape, opposite result, decided by whether there was something to carry.
The band is wider than the reason for it. The filter exists because fetch follows redirects, but it covers the whole 300–399, which is wider than what fetch calls a redirect: "A redirect status is a status that is 301, 302, 303, 307, or 308" (Fetch §2.2.3). So respond(undefined, { status: 304 }) — the natural answer for a cacheable read that has not changed — collapses to a 200 with a Void body that the client resolves to undefined, even though a 304 is never followed by anything.
And in every dropped case the Location header still rides along, so a scripted caller receives 200 + Location, which per HTTP semantics is not a redirect at all but a statement about the created/current resource.
Steps to reproduce
mkdir sf-3xx && cd sf-3xx && npm init -y
npm i @solidjs/web@2.0.0-rc.4
node repro.mjs
repro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { handleServerFunctionRequest, registerServerFunction } =
await import("@solidjs/web/server-functions/server");
const { respond, redirect } = await import("@solidjs/web");
const loc = { location: "/elsewhere" };
const cases = {
"return 302": () => respond(undefined, { status: 302, headers: loc }),
"throw 302": () => { throw respond(undefined, { status: 302, headers: loc }); },
"return 302+value": () => respond({ a: 1 }, { status: 302, headers: loc }),
"return 304": () => respond(undefined, { status: 304 }),
"throw 304": () => { throw respond(undefined, { status: 304 }); },
"throw redirect()": () => { throw redirect("/elsewhere"); },
"return 201": () => respond({ a: 1 }, { status: 201 })
};
for (const [name, fn] of Object.entries(cases)) registerServerFunction(name, fn);
const call = (id, scripted) =>
handleServerFunctionRequest(new Request(`http://localhost/_server/${encodeURIComponent(id)}`, {
method: "POST",
body: "[]",
headers: {
"Sec-Fetch-Site": "same-origin",
...(scripted ? { "X-Server-Function-Instance": "i" } : {})
}
}));
const fmt = r => `${r.status}${r.headers.get("location") ? " -> " + r.headers.get("location") : ""}`;
console.log("case | scripted | unscripted");
for (const name of Object.keys(cases)) {
console.log(name.padEnd(16), "|", fmt(await call(name, true)).padEnd(15), "|", fmt(await call(name, false)));
}
Output on 2.0.0-rc.4:
case | scripted | unscripted
return 302 | 200 -> /elsewhere | 200 -> /elsewhere
throw 302 | 200 -> /elsewhere | 302 -> /elsewhere
return 302+value | 200 -> /elsewhere | 302 -> /elsewhere
return 304 | 200 | 200
throw 304 | 200 | 304
throw redirect() | 200 -> /elsewhere | 302 -> /elsewhere
return 201 | 201 | 201
Expected behavior
One rule for 3xx, the same whether the envelope was returned or thrown and whether the value is empty — and a rule that applies to redirects rather than to the whole 3xx band.
Prior art
Dropping the real 3xx for a scripted caller is what everyone does, and for the reason above:
- Remix v2 built the whole response by hand: "We don't have any way to prevent a fetch request from following redirects. So we use the
X-Remix-Redirect header to indicate the next URL, and then 'follow' the redirect manually on the client" — a 204 carrying X-Remix-Redirect / X-Remix-Status, with Location deleted (server.ts).
- React Router v7 encodes it as data at status
202, and says why not 200: "We can't use a 3xx status or else the fetch() would follow the redirect… We use a 202 to avoid any automatic caching we might get from a 200 since a 'temporary' redirect should not be cached" (single-fetch.tsx).
- Next.js server actions answer
200 plus x-action-redirect: "Since this is not an HTTP redirect, keep the response successful" — the MPA path instead gets a real 303 + Location (action-handler.ts).
- SvelteKit returns
200 with a JSON envelope { type: 'redirect', status, location } (actions.js).
Two things they all do that this implementation does not. Every one of them moves the target out of Location into a dedicated marker, so the response never claims to be something it is not — here Location stays on the 200. And React Router picked 202 specifically so the non-redirect could not be cached, while a 200 can be.
For the band, React Router keeps exactly the Fetch set: export const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); (router.ts), and its single-fetch code deliberately excludes 304 from the statuses it treats as bodiless — "Note: 304 is not included here because the browser should fill those responses with the cached body content."
One thing worth stating plainly, since it cuts the other way: RFC 9110 §15.4 does call 304 a redirect ("Redirection to a previously stored result, as in the 304 (Not Modified) status code"). The argument for forwarding it is not that it isn't a redirect by that definition — it is that fetch never follows it, so the reason this filter exists does not apply to it.
Options
Withholding a real redirect from a scripted caller is right and should stay: fetch follows redirects by default, so a forwarded 302 would be chased and the payload swallowed. That is what redirect() is for, and the table shows it working. The problem is everything around that decision.
- Give the returned path the same guard as the thrown one. One line, and it removes both the return/throw split and the empty-value split: an unscripted caller gets the author's
3xx in every case, a scripted caller never does. Smallest fix that makes the table consistent.
- Narrow the band to the Fetch redirect set. Filter
301, 302, 303, 307, 308 instead of 300–399, so 304 (and 300, 305) forward normally — the same set React Router keeps. Independent of (1) and, I think, uncontroversial: the filter exists because fetch follows redirects, and it currently catches statuses fetch does not follow.
- Stop forwarding
Location when the status is dropped, or move the target to a dedicated header the way Remix, Next.js and SvelteKit all do. 200 + Location is a misleading response, and — React Router's reason for choosing 202 — a cacheable one. Something still has to carry the target for the client integration, which is what makes this a decision rather than a cleanup.
- Reject an author-chosen redirect at the call site, directing them to
redirect(). Honest, but it forecloses legitimate 303 after a mutation for no-JS callers.
- Document the current behaviour and amend RFC 10's "forwards status" sentence.
(1) and (2) together are what I would do: they are small, they cost no behaviour anyone relies on, and afterwards the rule is stateable in one sentence. (3) is worth its own decision.
Environment
|
|
@solidjs/web |
2.0.0-rc.4 (published), and next |
| Node |
v24.19.0 |
| OS |
macOS (darwin 25.6.0) |
Related
#3095 — a null-body status crashes the same encoder this issue's statuses pass through. #3097 — the status as a failure signal, the other half of "who decides what the response says". Option (3) here also touches #3094: a 200 is cacheable in a way React Router's 202 deliberately is not.
Describe the bug
Whether a
3xxstatus chosen by the author reaches the caller depends on three things that should not matter: whether the envelope was returned or thrown, whether the caller was scripted, and whether the value happened to be empty.RFC 10 promises this flatly — "The handler forwards the envelope's headers and status" — and for every other status it holds. For
3xxit does not:return respond(undefined, { status: 302, headers: { location } })LocationLocationthrow respond(undefined, { status: 302, headers: { location } })Locationreturn respond({ a: 1 }, { status: 302, headers: { location } })Locationreturn respond(undefined, { status: 304 })throw respond(undefined, { status: 304 })throw redirect("/elsewhere")Locationreturn respond({ a: 1 }, { status: 201 })Three things fall out of that table.
A returned redirect is dropped for everyone. The two paths guard the same thing differently (
packages/web/server-functions/src/server.ts) — the thrown path lets an unscripted caller through, the returned path does not:So a no-JS form post answering
return respond(undefined, { status: 302, headers: { location } })renders a 200 and never navigates.Whether the value is empty decides the outcome. An unscripted caller does get the 302 from a returned envelope if the value is non-empty, through an earlier passthrough (
if (!instance && !handleNoJS && response && response.body) return response;). Same intent, same call shape, opposite result, decided by whether there was something to carry.The band is wider than the reason for it. The filter exists because
fetchfollows redirects, but it covers the whole300–399, which is wider than whatfetchcalls a redirect: "A redirect status is a status that is 301, 302, 303, 307, or 308" (Fetch §2.2.3). Sorespond(undefined, { status: 304 })— the natural answer for a cacheable read that has not changed — collapses to a 200 with aVoidbody that the client resolves toundefined, even though a304is never followed by anything.And in every dropped case the
Locationheader still rides along, so a scripted caller receives200 + Location, which per HTTP semantics is not a redirect at all but a statement about the created/current resource.Steps to reproduce
repro.mjs:Output on 2.0.0-rc.4:
Expected behavior
One rule for
3xx, the same whether the envelope was returned or thrown and whether the value is empty — and a rule that applies to redirects rather than to the whole3xxband.Prior art
Dropping the real
3xxfor a scripted caller is what everyone does, and for the reason above:X-Remix-Redirectheader to indicate the next URL, and then 'follow' the redirect manually on the client" — a204carryingX-Remix-Redirect/X-Remix-Status, withLocationdeleted (server.ts).202, and says why not200: "We can't use a 3xx status or else thefetch()would follow the redirect… We use a 202 to avoid any automatic caching we might get from a 200 since a 'temporary' redirect should not be cached" (single-fetch.tsx).200plusx-action-redirect: "Since this is not an HTTP redirect, keep the response successful" — the MPA path instead gets a real303+Location(action-handler.ts).200with a JSON envelope{ type: 'redirect', status, location }(actions.js).Two things they all do that this implementation does not. Every one of them moves the target out of
Locationinto a dedicated marker, so the response never claims to be something it is not — hereLocationstays on the 200. And React Router picked202specifically so the non-redirect could not be cached, while a200can be.For the band, React Router keeps exactly the Fetch set:
export const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);(router.ts), and its single-fetch code deliberately excludes304from the statuses it treats as bodiless — "Note: 304 is not included here because the browser should fill those responses with the cached body content."One thing worth stating plainly, since it cuts the other way: RFC 9110 §15.4 does call
304a redirect ("Redirection to a previously stored result, as in the 304 (Not Modified) status code"). The argument for forwarding it is not that it isn't a redirect by that definition — it is thatfetchnever follows it, so the reason this filter exists does not apply to it.Options
Withholding a real redirect from a scripted caller is right and should stay:
fetchfollows redirects by default, so a forwarded 302 would be chased and the payload swallowed. That is whatredirect()is for, and the table shows it working. The problem is everything around that decision.3xxin every case, a scripted caller never does. Smallest fix that makes the table consistent.301, 302, 303, 307, 308instead of300–399, so304(and300,305) forward normally — the same set React Router keeps. Independent of (1) and, I think, uncontroversial: the filter exists becausefetchfollows redirects, and it currently catches statusesfetchdoes not follow.Locationwhen the status is dropped, or move the target to a dedicated header the way Remix, Next.js and SvelteKit all do.200 + Locationis a misleading response, and — React Router's reason for choosing202— a cacheable one. Something still has to carry the target for the client integration, which is what makes this a decision rather than a cleanup.redirect(). Honest, but it forecloses legitimate303after a mutation for no-JS callers.(1) and (2) together are what I would do: they are small, they cost no behaviour anyone relies on, and afterwards the rule is stateable in one sentence. (3) is worth its own decision.
Environment
@solidjs/webnextRelated
#3095 — a null-body status crashes the same encoder this issue's statuses pass through. #3097 — the status as a failure signal, the other half of "who decides what the response says". Option (3) here also touches #3094: a
200is cacheable in a way React Router's202deliberately is not.