Skip to content

Whether an author's 3xx survives depends on return vs throw and on whether the value is empty #3096

Description

@frenzzy

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 300399, 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.

  1. 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.
  2. Narrow the band to the Fetch redirect set. Filter 301, 302, 303, 307, 308 instead of 300399, 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.
  3. 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.
  4. 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.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions