Summary
A mutation that redirects loses single-flight whenever the request carries no Referer. digestOutcome computes outcome.targetUrl — "the URL the client will show after the mutation" — and its own comment states the rule as two cases: "the redirect Location when the outcome carries one (resolved against the request URL, as a browser would), the referring page otherwise". Only the second case needs a referer; the first is derived from the outcome the server itself just produced. Both sit inside one if (referrer), so a site that sends Referrer-Policy: no-referrer gets targetUrl === undefined on every redirecting mutation: the router's collector has no destination to produce data for, nothing folds, X-Single-Flight is absent, and the client pays a second round trip for the page it was just redirected to. Nothing warns — the feature silently stops paying.
Reproduction
packages/web/repro-flight-redirect-target.mjs, run from packages/web after npx rollup -c. It registers a mutation that throws a 303 to /orders/42 and a router-shaped flight-data source that can only produce data once it knows the destination, then calls it twice — once with a Referer (CONTROL), once without.
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const {
SINGLE_FLIGHT_HEADER,
handleServerFunctionRequest,
registerFlightDataSource,
registerServerFunction
} = await import("./server-functions/dist/server.js");
registerServerFunction("checkout", async () => {
throw new Response(null, { status: 303, headers: { Location: "/orders/42" } });
});
let seen;
registerFlightDataSource("router", (_event, outcome) => {
seen = outcome.targetUrl;
return outcome.targetUrl ? { [outcome.targetUrl]: ["destination data"] } : undefined;
});
async function call(label, referer) {
seen = undefined;
const response = await handleServerFunctionRequest(
new Request("http://localhost/_server/data/checkout", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
[SINGLE_FLIGHT_HEADER]: "router",
...(referer ? { referer } : {})
}
})
);
const body = await response.text();
console.log(
`${label.padEnd(34)} targetUrl=${String(seen).padEnd(28)} ` +
`${SINGLE_FLIGHT_HEADER}=${String(response.headers.get(SINGLE_FLIGHT_HEADER)).padEnd(6)} ` +
`body=${body}`
);
}
await call("CONTROL Referer: /cart", "http://localhost/cart");
await call("SUBJECT no Referer", undefined);
Measured, on f0f7531b:
CONTROL Referer: /cart targetUrl=http://localhost/orders/42 X-Single-Flight=router body={"value":null,"data":{"router":{"http://localhost/orders/42":["destination data"]}}}
SUBJECT no Referer targetUrl=undefined X-Single-Flight=null body=null
The two calls produced the same redirect. The only difference is a request header the destination does not depend on. The SUBJECT response is byte-identical to one from a build with no flight-data sources registered at all.
With the two lines reordered and the gate widened, same script:
CONTROL Referer: /cart targetUrl=http://localhost/orders/42 X-Single-Flight=router body={"value":null,"data":{"router":{"http://localhost/orders/42":["destination data"]}}}
SUBJECT no Referer targetUrl=http://localhost/orders/42 X-Single-Flight=router body={"value":null,"data":{"router":{"http://localhost/orders/42":["destination data"]}}}
Where
One site.
-
packages/web/server-functions/src/server.ts:1443-1448 (at f0f7531b) — digestOutcome:
const referrer = request.headers.get("referer");
if (referrer) {
const location = response?.headers.get("Location");
const target = location ? new URL(location, request.url) : new URL(referrer);
if (target.origin === new URL(request.url).origin) outcome.targetUrl = target.toString();
}
The location read is nested inside the referer gate that only the new URL(referrer) fallback needs.
-
The contract the code contradicts is stated twice in the same file: the block comment at packages/web/server-functions/src/server.ts:1430-1434, and the public targetUrl doc on the flight-outcome type at packages/web/server-functions/src/server.ts:141-149. Both say the Location case and the referer case are alternatives; neither says the Location case requires a referer.
Provenance: the gate is not a regression from a later fix. It arrived with the runtime.
$ git log --oneline -1 -S 'const referrer = request.headers.get("referer")' -- packages/web/src/server-functions/server.js
89a0531c Absorb expressions into Solid and collapse the rxcore seam.
$ git show 89a0531c --diff-filter=A --name-only | grep src/server-functions/server.js
packages/web/src/server-functions/server.js
89a0531c (Ryan Carniato, 2026-08-25) added packages/web/src/server-functions/server.js whole — 1231 lines, no parent version — already containing the gate in this shape. 71821959 ("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.", same day) moved it to packages/web/server-functions/src/server.ts and carried the six lines over verbatim, including the // unparseable referer — same as no referer catch comment. So the defect predates this repo's history of the file and was never introduced by a fix in a neighbouring commit.
Why it matters
Nothing is corrupted and nothing is exposed. The cost is purely that a performance feature stops working, silently, for a subset of sites.
The realistic path: an app sets Referrer-Policy: no-referrer — a header sites set deliberately, and one that hardening middleware commonly applies — and a form submits a mutation that redirects to the created resource, which is the canonical shape single-flight exists for. The mutation's response comes back with no folded data, the router navigates to the destination, and fetches it in a second request. The app works; it is just one round trip slower on every mutation, with no error, no warning, and nothing in the response to point at. That is the kind of regression that gets attributed to the network rather than to a header.
Honest limits on reachability:
- Only a policy that strips
Referer entirely triggers this. origin, strict-origin, and strict-origin-when-cross-origin all still send a same-origin-parseable value, so the gate opens and the Location branch runs as documented. no-referrer is the case, plus no-referrer-when-downgrade on a plaintext-to-TLS hop.
- Non-browser callers — a test harness, an SDK,
curl — also send no Referer, and they hit the same gate. Whether they should get a targetUrl is a real question, addressed under Options; today they get none regardless of whether the mutation redirected.
- The client transport does not set
referrerPolicy on its fetch init (packages/web/server-functions/src/client.ts), so what gets sent is whatever the document's policy dictates. There is no runtime override in play; this is entirely the app's header.
- The
Location-less half of the rule is unaffected. A non-redirecting mutation under no-referrer has genuinely no page to produce data for, and correctly produces no target.
Options
-
Read Location before the gate and widen it to if (location || referrer). Two reordered lines. Restores exactly what both comments already promise, adds no API, no config, no wire surface. Behaviour change: a redirecting mutation from a non-browser caller now yields a targetUrl where it previously yielded none — which is arguably correct (the destination is the server's own output, not a claim by the caller) but is a change a collector could observe. The cross-origin guard has to keep holding on both halves once the Location half no longer sits behind the referer check, since the gate was incidentally providing part of that containment.
-
Leave the runtime alone; document that single-flight requires a Referer. Zero behaviour risk. The cost is that the docs would then have to contradict the function's own block comment and the public targetUrl doc on the flight-outcome type, both of which state the two cases as alternatives — so this is a three-site edit that makes the shipped contract worse, to preserve behaviour nobody appears to have chosen.
-
Have the client transport send the current page URL as an explicit header, so core never depends on Referer. This is the only option that also fixes the non-redirecting case under no-referrer, which option 1 leaves in place. Against it: a new protocol header on every server-function request, a new trust question (the client now asserts its own location, where Location is server-derived), and it does nothing for no-JS form posts, which reach digestOutcome through the same path with no client runtime to add a header.
-
Set referrerPolicy on the transport's fetch init. Rejected on principle: the runtime would be overriding a security header the app deliberately set. It also does not help form posts.
-
Push the derivation to the integration — let each CollectFlightDataHook compute its own target. Every integration would re-derive Location-vs-referer resolution and the origin check from raw headers, which is precisely the duplication digestOutcome exists to remove.
Recommendation: option 1. In Solid's minimalism terms it is the smallest possible change that makes the code agree with its own stated rule — no new surface, no new header, no configuration knob, and the resulting function is shorter to read than the one it replaces. Options 3 and 4 add machinery to work around a condition the server already has the answer to.
Two calls belong to a maintainer rather than to this report. First, whether the widened gate's new behaviour for non-browser callers is wanted, or whether the Location branch should additionally require something identifying a browser — I read the outcome-derived destination as legitimate for any caller, but that is a contract decision. Second, whether losing single-flight on non-redirecting mutations under no-referrer is worth addressing at all, and if so whether it belongs in the runtime or in an adapter — that is a separate change from this one, and option 1 does not foreclose it.
Regression test
packages/web/test/server/server-functions-flight-redirect-target.spec.tsx, two cases in one describe. It registers a redirecting mutation and a router-shaped collector, calls with no Referer, and pins the rule as the comment states it — including the guards the referer gate was incidentally providing, so widening the gate cannot quietly drop them.
function redirectingMutation(id: string, location: string) {
const outcomes: any[] = [];
registerServerFunction(id, async () => {
throw new Response(null, { status: 303, headers: { Location: location } });
});
unregisters.push(
registerFlightDataSource("router", (_event: any, outcome: any) => {
outcomes.push(outcome);
return outcome.targetUrl ? { [outcome.targetUrl]: ["destination data"] } : undefined;
})
);
const call = (referer?: string) =>
handleServerFunctionRequest(
new Request(`http://localhost/_server/data/${id}`, {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
[SINGLE_FLIGHT_HEADER]: "router",
...(referer ? { referer } : {})
}
})
);
return { call, outcomes };
}
describe("single-flight target url for a redirecting mutation", () => {
it("derives the target from the redirect Location when the caller sends no Referer", async () => {
const sameOrigin = redirectingMutation("sf-target-noreferer", "/orders/42");
await sameOrigin.call();
const derived = sameOrigin.outcomes.at(-1);
expect(
derived.targetUrl,
`Location was ${derived.response?.headers?.get("Location")} but targetUrl was ` +
`${derived.targetUrl}`
).toBe("http://localhost/orders/42");
// The guards the referer gate was incidentally providing have to keep
// holding once the Location half no longer sits behind it: a redirect
// leaving the app's origin has no page of ours to produce data for, and
// a non-redirecting mutation still falls back to the referring page —
// which a caller that sends none does not have.
const crossOrigin = redirectingMutation(
"sf-target-crossorigin",
"https://elsewhere.example/orders/42"
);
await crossOrigin.call();
expect(
crossOrigin.outcomes.at(-1).targetUrl,
"a redirect leaving the origin must still produce no target"
).toBeUndefined();
registerServerFunction("sf-target-plain", async () => "committed");
const plain: any[] = [];
unregisters.push(
registerFlightDataSource("router", (_event: any, outcome: any) => {
plain.push(outcome);
return undefined;
})
);
await handleServerFunctionRequest(
new Request("http://localhost/_server/data/sf-target-plain", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
[SINGLE_FLIGHT_HEADER]: "router"
}
})
);
expect(
plain.at(-1).targetUrl,
"a non-redirecting mutation without a Referer must still produce no target"
).toBeUndefined();
});
it("still folds destination data for a mutation sent under Referrer-Policy: no-referrer", async () => {
// The wire-visible cost of the gate: the response is byte-identical to
// a call with no hooks at all, so the client has to go back for the
// destination it was already redirected to.
const { call } = redirectingMutation("sf-target-fold", "/orders/42");
const response = await call();
const body = await response.clone().text();
expect(
response.headers.get(SINGLE_FLIGHT_HEADER),
`nothing folded — the mutation costs a second round trip; body was ${body}`
).toBe("router");
expect(await decodeResponse(response)).toEqual({
value: null,
data: { router: { "http://localhost/orders/42": ["destination data"] } }
});
});
});
Both go red on f0f7531b — the first on expected undefined to be "http://localhost/orders/42", the second on nothing folded — the mutation costs a second round trip; body was null: expected null to be 'router' — and both pass with the gate widened. The full test/server suite is green with the change: 84 files, 737 passed, 2 skipped.
Summary
A mutation that redirects loses single-flight whenever the request carries no
Referer.digestOutcomecomputesoutcome.targetUrl— "the URL the client will show after the mutation" — and its own comment states the rule as two cases: "the redirectLocationwhen the outcome carries one (resolved against the request URL, as a browser would), the referring page otherwise". Only the second case needs a referer; the first is derived from the outcome the server itself just produced. Both sit inside oneif (referrer), so a site that sendsReferrer-Policy: no-referrergetstargetUrl === undefinedon every redirecting mutation: the router's collector has no destination to produce data for, nothing folds,X-Single-Flightis absent, and the client pays a second round trip for the page it was just redirected to. Nothing warns — the feature silently stops paying.Reproduction
packages/web/repro-flight-redirect-target.mjs, run frompackages/webafternpx rollup -c. It registers a mutation that throws a303to/orders/42and a router-shaped flight-data source that can only produce data once it knows the destination, then calls it twice — once with aReferer(CONTROL), once without.Measured, on
f0f7531b:The two calls produced the same redirect. The only difference is a request header the destination does not depend on. The SUBJECT response is byte-identical to one from a build with no flight-data sources registered at all.
With the two lines reordered and the gate widened, same script:
Where
One site.
packages/web/server-functions/src/server.ts:1443-1448(atf0f7531b) —digestOutcome:The
locationread is nested inside the referer gate that only thenew URL(referrer)fallback needs.The contract the code contradicts is stated twice in the same file: the block comment at
packages/web/server-functions/src/server.ts:1430-1434, and the publictargetUrldoc on the flight-outcome type atpackages/web/server-functions/src/server.ts:141-149. Both say theLocationcase and the referer case are alternatives; neither says theLocationcase requires a referer.Provenance: the gate is not a regression from a later fix. It arrived with the runtime.
89a0531c(Ryan Carniato, 2026-08-25) addedpackages/web/src/server-functions/server.jswhole — 1231 lines, no parent version — already containing the gate in this shape.71821959("Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.", same day) moved it topackages/web/server-functions/src/server.tsand carried the six lines over verbatim, including the// unparseable referer — same as no referercatch comment. So the defect predates this repo's history of the file and was never introduced by a fix in a neighbouring commit.Why it matters
Nothing is corrupted and nothing is exposed. The cost is purely that a performance feature stops working, silently, for a subset of sites.
The realistic path: an app sets
Referrer-Policy: no-referrer— a header sites set deliberately, and one that hardening middleware commonly applies — and a form submits a mutation that redirects to the created resource, which is the canonical shape single-flight exists for. The mutation's response comes back with no folded data, the router navigates to the destination, and fetches it in a second request. The app works; it is just one round trip slower on every mutation, with no error, no warning, and nothing in the response to point at. That is the kind of regression that gets attributed to the network rather than to a header.Honest limits on reachability:
Refererentirely triggers this.origin,strict-origin, andstrict-origin-when-cross-originall still send a same-origin-parseable value, so the gate opens and theLocationbranch runs as documented.no-referreris the case, plusno-referrer-when-downgradeon a plaintext-to-TLS hop.curl— also send noReferer, and they hit the same gate. Whether they should get atargetUrlis a real question, addressed under Options; today they get none regardless of whether the mutation redirected.referrerPolicyon its fetch init (packages/web/server-functions/src/client.ts), so what gets sent is whatever the document's policy dictates. There is no runtime override in play; this is entirely the app's header.Location-less half of the rule is unaffected. A non-redirecting mutation underno-referrerhas genuinely no page to produce data for, and correctly produces no target.Options
Read
Locationbefore the gate and widen it toif (location || referrer). Two reordered lines. Restores exactly what both comments already promise, adds no API, no config, no wire surface. Behaviour change: a redirecting mutation from a non-browser caller now yields atargetUrlwhere it previously yielded none — which is arguably correct (the destination is the server's own output, not a claim by the caller) but is a change a collector could observe. The cross-origin guard has to keep holding on both halves once theLocationhalf no longer sits behind the referer check, since the gate was incidentally providing part of that containment.Leave the runtime alone; document that single-flight requires a
Referer. Zero behaviour risk. The cost is that the docs would then have to contradict the function's own block comment and the publictargetUrldoc on the flight-outcome type, both of which state the two cases as alternatives — so this is a three-site edit that makes the shipped contract worse, to preserve behaviour nobody appears to have chosen.Have the client transport send the current page URL as an explicit header, so core never depends on
Referer. This is the only option that also fixes the non-redirecting case underno-referrer, which option 1 leaves in place. Against it: a new protocol header on every server-function request, a new trust question (the client now asserts its own location, whereLocationis server-derived), and it does nothing for no-JS form posts, which reachdigestOutcomethrough the same path with no client runtime to add a header.Set
referrerPolicyon the transport's fetch init. Rejected on principle: the runtime would be overriding a security header the app deliberately set. It also does not help form posts.Push the derivation to the integration — let each
CollectFlightDataHookcompute its own target. Every integration would re-deriveLocation-vs-referer resolution and the origin check from raw headers, which is precisely the duplicationdigestOutcomeexists to remove.Recommendation: option 1. In Solid's minimalism terms it is the smallest possible change that makes the code agree with its own stated rule — no new surface, no new header, no configuration knob, and the resulting function is shorter to read than the one it replaces. Options 3 and 4 add machinery to work around a condition the server already has the answer to.
Two calls belong to a maintainer rather than to this report. First, whether the widened gate's new behaviour for non-browser callers is wanted, or whether the
Locationbranch should additionally require something identifying a browser — I read the outcome-derived destination as legitimate for any caller, but that is a contract decision. Second, whether losing single-flight on non-redirecting mutations underno-referreris worth addressing at all, and if so whether it belongs in the runtime or in an adapter — that is a separate change from this one, and option 1 does not foreclose it.Regression test
packages/web/test/server/server-functions-flight-redirect-target.spec.tsx, two cases in one describe. It registers a redirecting mutation and a router-shaped collector, calls with noReferer, and pins the rule as the comment states it — including the guards the referer gate was incidentally providing, so widening the gate cannot quietly drop them.Both go red on
f0f7531b— the first onexpected undefined to be "http://localhost/orders/42", the second onnothing folded — the mutation costs a second round trip; body was null: expected null to be 'router'— and both pass with the gate widened. The fulltest/serversuite is green with the change: 84 files, 737 passed, 2 skipped.