Skip to content

A redirecting mutation loses single-flight whenever the page sends no Referer #3252

Description

@frenzzy

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

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