Skip to content

A form navigation refused before dispatch is left on /_server/<id> with everything the user typed gone #3250

Description

@frenzzy

Summary

A form navigation that is refused before dispatch leaves the browser sitting on /_server/<id>. A stale function id after a deploy answers 404, a malformed multipart body 400, an upload past bodySizeLimit 413, and a request the origin check cannot vouch for 403 — each with no Location and, in production, no body at all. The user gets a blank page at the endpoint, the back button as the only way out, and everything they typed is gone. createNoJSHandler's contract is stated as an absolute — "the browser is never left on the endpoint" (the describe name in packages/web/test/server/server-functions-nojs-destination.spec.tsx:53) — but the convention is chosen at server.ts:3055, after every one of those gates, so it is never handed the refusals. Nothing has committed at any of these exits, which is why this is a progressive-enhancement hole rather than a correctness one, and also why the ordinary bounce back to the form is a safe answer.

This merges four symptoms that could be filed separately — stale-id 404 strands the browser, malformed-multipart 400 strands the browser, oversized-upload 413 strands the browser, origin-refusal 403 strands the browser. They are one bug: the no-JS decision sits below the gates. The scripted form-shape 400 (server.ts:3067 at baseline) is deliberately not part of this — it refuses the call, not the browser.

Reproduction

repro/nojs-refusal.mjs, run against a built packages/web (pnpm --filter @solidjs/web build):

import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();

const { handleServerFunctionRequest, registerServerFunction } = await import(
  process.env.SOLID_WEB + "/server-functions/dist/server.js"
);

const ORIGIN = "https://app.example";
let ran = 0;
registerServerFunction("save", async () => { ran++; return { ok: true }; });

// A real browser form navigation: no instance header, a form content type,
// and no Sec-Fetch-Mode (which dispatch reads as navigate).
function nav(id, { contentType = "application/x-www-form-urlencoded", body = "name=Ada", referer = ORIGIN + "/settings" } = {}) {
  const headers = { "Sec-Fetch-Site": "same-origin", "Content-Type": contentType };
  if (referer) headers.Referer = referer;
  return new Request(`${ORIGIN}/_server/${id}`, { method: "POST", headers, body });
}

const rows = [
  ["CONTROL  form nav, function runs", () => handleServerFunctionRequest(nav("save"))],
  ["CONTROL  direct HTTP (curl), stale id", () =>
    handleServerFunctionRequest(new Request(`${ORIGIN}/_server/retired`, {
      method: "POST",
      headers: { "Sec-Fetch-Site": "same-origin", "Content-Type": "application/json" },
      body: "[]"
    }))],
  ["CONTROL  page script, form-shaped fetch", () =>
    handleServerFunctionRequest(new Request(`${ORIGIN}/_server/save`, {
      method: "POST",
      headers: {
        "Sec-Fetch-Site": "same-origin",
        "Sec-Fetch-Mode": "cors",
        "Content-Type": "application/x-www-form-urlencoded"
      },
      body: "name=Ada"
    }))],
  ["form nav, id retired by a deploy", () => handleServerFunctionRequest(nav("retired"))],
  ["form nav, malformed multipart body", () =>
    handleServerFunctionRequest(nav("save", {
      contentType: "multipart/form-data; boundary=----SolidBoundary",
      body: "this is not a multipart body"
    }))],
  ["form nav, upload past bodySizeLimit", () =>
    handleServerFunctionRequest(nav("save", { body: "note=" + "A".repeat(5000) }), { bodySizeLimit: 100 })],
  ["form nav, origin check cannot vouch", () =>
    handleServerFunctionRequest(new Request(`${ORIGIN}/_server/save`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "name=Ada"
    }))]
];

console.log("case".padEnd(36), "status", "Location".padEnd(12), "flash", "ran");
for (const [name, run] of rows) {
  const before = ran;
  const r = await run();
  const loc = r.headers.get("Location");
  const flash = r.headers.getSetCookie().some(c => /^(__Host-)?flash=/.test(c));
  console.log(
    name.padEnd(36),
    String(r.status).padEnd(6),
    String(loc ?? "(none)").padEnd(12),
    String(flash).padEnd(5),
    ran - before
  );
}

Measured on f0f7531b:

case                                 status Location     flash ran
CONTROL  form nav, function runs     303    https://app.example/settings true  1
CONTROL  direct HTTP (curl), stale id 404    (none)       false 0
CONTROL  page script, form-shaped fetch 400    (none)       false 0
form nav, id retired by a deploy     404    (none)       false 0
form nav, malformed multipart body   400    (none)       false 0
form nav, upload past bodySizeLimit  413    (none)       false 0
form nav, origin check cannot vouch  403    (none)       false 0

The first control is the same request shape reaching a live function: 303, a Location back to /settings, the outcome flashed. The four rows below it are the same browser doing the same thing and getting no Location. ran is 0 on every one of them: no mutation ran, so there is nothing to double-submit.

Measured after the fix, same script:

case                                 status Location     flash ran
CONTROL  form nav, function runs     303    https://app.example/settings true  1
CONTROL  direct HTTP (curl), stale id 404    (none)       false 0
CONTROL  page script, form-shaped fetch 400    (none)       false 0
form nav, id retired by a deploy     303    https://app.example/settings true  0
form nav, malformed multipart body   303    https://app.example/settings true  0
form nav, upload past bodySizeLimit  303    https://app.example/settings true  0
form nav, origin check cannot vouch  303    https://app.example/ true  0

The two non-browser controls are unchanged — that is the point of the fix's scope. The last row lands on / rather than /settings because the request that trips the origin gate is by construction one with no Referer; that is what createNoJSHandler's base is for.

What the next render is told, decoded from the flash cookie:

stale id       -> 303 | {"url":"/_server/retired","error":"Error: This page is out of date: the server function it submitted to is no longer deployed."}
bodySizeLimit  -> 303 | {"url":"/_server/save","error":"Error: The submission was refused before it ran (413)."}

Only the version-skew refusal gets a message the user can act on; the rest carry the status and nothing more, because in production the reason text is not on the wire to begin with.

Where

All line numbers are at f0f7531b, in packages/web/server-functions/src/server.ts.

  • 3055-3059 — the decision, and the actual defect. let handleNoJS = …; if (handleNoJS === undefined && !scripted && isFormPost(request)) sits below every gate. This placement is original to the flattened runtime: at 71821959 (2026-08-25, Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders) the convention block was already at line 1660 with the 403 at 1600 and the 404s at 1606/1615. be7bcd2e (fix(web): key the no-JS convention on Sec-Fetch-Mode, not header absence (At the bare address the no-JS answer shape is decided by the absence of a header #3139)) rewrote what is inside the block but did not move it.

Each gate below it was added by a later fix, and each one widened the stranding class:

The fix, for reference, is at server.ts:2991 (refusalError), 3177-3235 (the decision hoisted above the gates plus the single refuse(response, vary) seam), and twelve return refuse(...) call sites; refuseCommitted is deleted and subsumed. The scripted form-shape 400 stays behind the gates, at 3436-3456.

Why it matters

The reachable path is the one #3110 was written for: you deploy, a user has the page open, they submit, the id in their HTML is not in the new build. On the scripted path that surfaces as a labelled 404 the client can act on. On the no-JS path — a form posting straight to the bare address, which is the whole point of the convention — they get a blank page at /_server/9f2c… and a filled-in form they now have to retype. The 413 is the same story with a more mundane trigger: a photo attached to a form, over bodySizeLimit, and the answer is a blank 413 instead of "that file is too big, here is your form back". The 400 covers an upload that dies mid-flight on a flaky connection.

Honest limits, because they are real:

  • This only reaches apps that actually render forms posting to the bare server-function address — progressive enhancement, or the window before hydration. An app whose every mutation goes through the client runtime never sees it. If you do not use the no-JS convention, this issue does not affect you.
  • Nothing commits. ran is 0 on every refused row above. There is no double-submit, no partial write, no integrity problem. The cost is the user's typed input and a dead-end page, not data.
  • The 403 row is the narrowest of the four. Reaching it needs a POST carrying no Sec-Fetch-Site, no Origin and no Referer — current Chrome, Firefox and Safari all send at least Origin on a form POST, so in practice this is an older browser, a header-stripping proxy, or an embedded webview. I would not file this row on its own; it is included because it exits through the same seam and the fix covers it for free.
  • The reason text is DEV-only. In production these responses have no body at all, which is what makes the blank page blank.

Options

  1. Document the limit instead of changing behaviour. Amend createNoJSHandler's doc so "the browser is never left on the endpoint" reads "for every call it is handed", and say plainly that pre-dispatch refusals are not handed to it. Zero risk, zero code. Against it: the contract as written is an absolute, and the case where the user most needs the bounce is the one where something already went wrong.

  2. Fix it in the adapter. Let SolidStart (or any integration) notice a non-2xx from the server-function endpoint on a navigation and redirect. Keeps the runtime out of it. Against it: every adapter reimplements the same thing, the flash cookie encoding lives in the runtime anyway, and from outside the handler an adapter cannot distinguish "refused before dispatch, nothing committed, safe to bounce" from a 409 the function itself returned after committing — which is exactly the distinction that makes the bounce safe.

  3. Move only the decision above the gates, and route every pre-dispatch refusal through one seam. What is implemented here: formShaped / formNavigation / handleNoJS are resolved before the id lookup, and a single refuse(response, vary) closure either hands the refusal to handleNoJS (browser form navigation) or returns the plain response (everyone else), folding the event stub when one exists. Against it: it is a behaviour change on the wire — 303 where 404/400/413/403 used to be, for browser form navigations only.

  4. Bounce, but do not flash. Smaller surface, no cookie written on a refusal. Against it: a silent bounce back to an unchanged form reads as "nothing happened", and that is the read that makes a user submit again — the same reasoning as The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently #3137, which added the cookie for the falsy-result case.

  5. Put it behind a flag (handleNoJS: { refusals: true } or similar). No change for existing deployments. Against it: the people who need it are the ones running the built-in convention with no configuration at all, and a flag they never see does not help them.

Recommendation: 3, with the flash (4's answer folded in). It wins on Solid's own terms because it is subtractive: no new API, no new option, no new concept. refuseCommitted disappears, the finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method) incantation that was copy-pasted eight times collapses into one seam, and the stub-fold from #3159 stops being a thing that only applies below createEvent. It does not add a second contract; it makes the one already written true.

Two things are genuinely the maintainer's call, not mine:

  • Is the status change acceptable? Operators watching 4xx rates will see 303s where they saw 404/413. Direct HTTP and scripted callers keep their exact status, and the two control rows above are there to prove it, so the blast radius is browser form navigations only — but it is still a wire change in an RC.
  • Should the generic refusal be flashed in production? refusalError gives the version-skew case a message the user can act on and everything else a bare The submission was refused before it ran (413). Flashing that at all is a judgement about how much a production build should say; the alternative is to flash only the skew case and bounce the rest silently.

If the answer to the first is no, option 1 is the honest fallback and this becomes a docs change.

Regression test

packages/web/test/server/server-functions-nojs-refusal-destination.spec.tsx (the file's header comment carries the reasoning; the body is below verbatim):

import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
  FLASH_COOKIE,
  decodeFlashCookie,
  handleServerFunctionRequest,
  registerServerFunction
} from "@solidjs/web/server-functions/server";

const RequestContext = Symbol.for("solid.RequestContext");

beforeAll(() => {
  (globalThis as any)[RequestContext] = new AsyncLocalStorage();
});

afterAll(() => {
  delete (globalThis as any)[RequestContext];
});

const ORIGIN = "https://app.example";
const FORM_PAGE = `${ORIGIN}/settings`;

let ran = 0;
registerServerFunction("nojs-refusal-save", async (...args: unknown[]) => {
  ran++;
  return { saved: args.length };
});

/**
 * A real browser form navigation: no `X-Server-Function-Instance`, a form
 * content type, and no `Sec-Fetch-Mode` (which dispatch reads as navigate,
 * the older-browser spelling — #3139).
 */
function formNavigation(
  id: string,
  {
    contentType = "application/x-www-form-urlencoded",
    body = "name=Ada",
    referer = FORM_PAGE as string | null
  } = {}
) {
  const headers: Record<string, string> = {
    "Sec-Fetch-Site": "same-origin",
    "Content-Type": contentType
  };
  if (referer) headers.Referer = referer;
  return new Request(`${ORIGIN}/_server/${id}`, { method: "POST", headers, body });
}

/** The destination assertion the convention promises, whatever went wrong. */
function expectsBounceBack(response: Response, message: string) {
  const location = response.headers.get("Location");
  expect(response.status, `${message} — status ${response.status}`).toBe(303);
  expect(location, `${message} — no Location, the browser stays on the endpoint`).not.toBeNull();
  expect(new URL(location!, ORIGIN).origin).toBe(ORIGIN);
}

function flashed(response: Response) {
  const cookie = response.headers
    .getSetCookie()
    .find(entry => entry.startsWith(`${FLASH_COOKIE}=`));
  return cookie ? decodeFlashCookie(cookie.split(";")[0]) : undefined;
}

describe("a refused form navigation is bounced back, not stranded", () => {
  it("returns to the form when the id is stale after a deploy", async () => {
    const before = ran;
    const response = await handleServerFunctionRequest(formNavigation("nojs-refusal-retired-id"));

    expect(ran - before).toBe(0); // nothing registered at that id could have run
    expectsBounceBack(response, "unknown server function");
  });

  it("tells the next render that the stale-id submission failed", async () => {
    const response = await handleServerFunctionRequest(formNavigation("nojs-refusal-retired-id-2"));

    // a silent bounce back to the same form reads as "nothing happened",
    // which is the read that makes a user submit again
    const submission = flashed(response);
    expect(submission, "no outcome cookie — the form re-renders as if untouched").toBeDefined();
    expect(submission?.error).toBeInstanceOf(Error);
    expect(submission?.result).toBeUndefined();
  });

  it("returns to the form when the multipart body is malformed", async () => {
    const before = ran;
    const response = await handleServerFunctionRequest(
      formNavigation("nojs-refusal-save", {
        contentType: "multipart/form-data; boundary=----SolidBoundary",
        body: "this is not a multipart body"
      })
    );

    expect(ran - before).toBe(0);
    expectsBounceBack(response, "malformed arguments");
  });

  it("returns to the form when the upload runs past bodySizeLimit", async () => {
    const before = ran;
    const response = await handleServerFunctionRequest(
      formNavigation("nojs-refusal-save", { body: "note=" + "A".repeat(5000) }),
      { bodySizeLimit: 100 }
    );

    expect(ran - before).toBe(0);
    expectsBounceBack(response, "body over the limit");
  });

  it("returns to the app when the origin check cannot vouch for the post", async () => {
    // a privacy extension, a `Referrer-Policy: no-referrer` page, an
    // embedded webview: no fetch metadata, so the gate refuses — and there
    // is no referer to return to either, which is what `base` is for
    const before = ran;
    const response = await handleServerFunctionRequest(
      new Request(`${ORIGIN}/_server/nojs-refusal-save`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: "name=Ada"
      })
    );

    expect(ran - before).toBe(0);
    expectsBounceBack(response, "origin check refused");
  });
});

Against f0f7531b all five go red on the destination assertion — AssertionError: origin check refused — status 403: expected 403 to be 303 — while the ran counters stay at 0, which is what pins the "nothing committed, so the bounce is safe" half of the argument rather than assuming it.

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