You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 bodySizeLimit413, 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")]=newAsyncLocalStorage();const{ handleServerFunctionRequest, registerServerFunction }=awaitimport(process.env.SOLID_WEB+"/server-functions/dist/server.js");constORIGIN="https://app.example";letran=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).functionnav(id,{ contentType ="application/x-www-form-urlencoded", body ="name=Ada", referer =ORIGIN+"/settings"}={}){constheaders={"Sec-Fetch-Site": "same-origin","Content-Type": contentType};if(referer)headers.Referer=referer;returnnewRequest(`${ORIGIN}/_server/${id}`,{method: "POST", headers, body });}constrows=[["CONTROL form nav, function runs",()=>handleServerFunctionRequest(nav("save"))],["CONTROL direct HTTP (curl), stale id",()=>handleServerFunctionRequest(newRequest(`${ORIGIN}/_server/retired`,{method: "POST",headers: {"Sec-Fetch-Site": "same-origin","Content-Type": "application/json"},body: "[]"}))],["CONTROL page script, form-shaped fetch",()=>handleServerFunctionRequest(newRequest(`${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(newRequest(`${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]ofrows){constbefore=ran;constr=awaitrun();constloc=r.headers.get("Location");constflash=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:
2955-2963 — the malformed/aborted-body 400, from e220cfae (2026-09-02, fix(web): own server function body teardown), which is the most recent addition to the class.
3007 — the createEvent-failure exit, and 3017-3020 — refuseCommitted, from 0b9d69a3 (2026-08-31, fix: fold the event response stub onto post-createEvent refusals (Refusals after createEvent drop the response stub's Set-Cookie silently #3159)). refuseCommitted is a partial version of the same seam: it folds the event stub onto refusals but has nothing to say about destination, and it only exists below createEvent.
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
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.
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.
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.
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";constRequestContext=Symbol.for("solid.RequestContext");beforeAll(()=>{(globalThisasany)[RequestContext]=newAsyncLocalStorage();});afterAll(()=>{delete(globalThisasany)[RequestContext];});constORIGIN="https://app.example";constFORM_PAGE=`${ORIGIN}/settings`;letran=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). */functionformNavigation(id: string,{
contentType ="application/x-www-form-urlencoded",
body ="name=Ada",
referer =FORM_PAGEasstring|null}={}){constheaders: Record<string,string>={"Sec-Fetch-Site": "same-origin","Content-Type": contentType};if(referer)headers.Referer=referer;returnnewRequest(`${ORIGIN}/_server/${id}`,{method: "POST", headers, body });}/** The destination assertion the convention promises, whatever went wrong. */functionexpectsBounceBack(response: Response,message: string){constlocation=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(newURL(location!,ORIGIN).origin).toBe(ORIGIN);}functionflashed(response: Response){constcookie=response.headers.getSetCookie().find(entry=>entry.startsWith(`${FLASH_COOKIE}=`));returncookie ? 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()=>{constbefore=ran;constresponse=awaithandleServerFunctionRequest(formNavigation("nojs-refusal-retired-id"));expect(ran-before).toBe(0);// nothing registered at that id could have runexpectsBounceBack(response,"unknown server function");});it("tells the next render that the stale-id submission failed",async()=>{constresponse=awaithandleServerFunctionRequest(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 againconstsubmission=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()=>{constbefore=ran;constresponse=awaithandleServerFunctionRequest(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()=>{constbefore=ran;constresponse=awaithandleServerFunctionRequest(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 forconstbefore=ran;constresponse=awaithandleServerFunctionRequest(newRequest(`${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.
Summary
A form navigation that is refused before dispatch leaves the browser sitting on
/_server/<id>. A stale function id after a deploy answers404, a malformed multipart body400, an upload pastbodySizeLimit413, and a request the origin check cannot vouch for403— each with noLocationand, 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 inpackages/web/test/server/server-functions-nojs-destination.spec.tsx:53) — but the convention is chosen atserver.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:3067at baseline) is deliberately not part of this — it refuses the call, not the browser.Reproduction
repro/nojs-refusal.mjs, run against a builtpackages/web(pnpm --filter @solidjs/web build):Measured on
f0f7531b:The first control is the same request shape reaching a live function:
303, aLocationback to/settings, the outcome flashed. The four rows below it are the same browser doing the same thing and getting noLocation.ranis0on every one of them: no mutation ran, so there is nothing to double-submit.Measured after the fix, same script:
The two non-browser controls are unchanged — that is the point of the fix's scope. The last row lands on
/rather than/settingsbecause the request that trips the origin gate is by construction one with noReferer; that is whatcreateNoJSHandler'sbaseis for.What the next render is told, decoded from the flash cookie:
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, inpackages/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: at71821959(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 the403at 1600 and the404s 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:
2866-2872— the labelled unknown-id 404. Introduced by2f6d8cc7(2026-08-29, feat(web): label the unknown-id 404 so version skew is recoverable (A stale server-function id is an undistinguishable 404, so version skew cannot be recovered #3110)). This is the sharpest case: A stale server-function id is an undistinguishable 404, so version skew cannot be recovered #3110 exists precisely because a tab holding the previous build's ids is a normal thing to have, and the label it adds is unreadable to a browser that is handed a bodiless 404.2875-2877— the CSRF403(return finalizeTransportResponse(forbiddenResponse(), method)), from258c76ad(2026-08-26, fix(web): method allowlist, HEAD support, and cache hygiene for server function requests (HEAD requests bypass the server function method gate and execute any registered function #3069, Server function responses ship no Cache-Control, and a CSRF Vary that defeats GET caching #3071)).2880-2883— the meaningless-path404and2904-2912— the method-allowlist405, same commit family; the405text predates in71821959.2924-2930,2966-2974— the413s (declaredContent-Lengthover the cap, and the measured-bytes cap), from51392f36(2026-08-30, feat(web): bound server-function request payloads (A server function accepts a body of any size and any number of arguments #3115, The decode depth cap is opt-out: the caller picks the format that skips it #3119)).2955-2963— the malformed/aborted-body400, frome220cfae(2026-09-02, fix(web): own server function body teardown), which is the most recent addition to the class.3007— thecreateEvent-failure exit, and3017-3020—refuseCommitted, from0b9d69a3(2026-08-31, fix: fold the event response stub onto post-createEvent refusals (Refusals after createEvent drop the response stub's Set-Cookie silently #3159)).refuseCommittedis a partial version of the same seam: it folds the event stub onto refusals but has nothing to say about destination, and it only exists belowcreateEvent.The fix, for reference, is at
server.ts:2991(refusalError),3177-3235(the decision hoisted above the gates plus the singlerefuse(response, vary)seam), and twelvereturn refuse(...)call sites;refuseCommittedis deleted and subsumed. The scripted form-shape400stays behind the gates, at3436-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. The413is the same story with a more mundane trigger: a photo attached to a form, overbodySizeLimit, and the answer is a blank 413 instead of "that file is too big, here is your form back". The400covers an upload that dies mid-flight on a flaky connection.Honest limits, because they are real:
ranis0on 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.403row is the narrowest of the four. Reaching it needs a POST carrying noSec-Fetch-Site, noOriginand noReferer— current Chrome, Firefox and Safari all send at leastOriginon 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.Options
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.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
409the function itself returned after committing — which is exactly the distinction that makes the bounce safe.Move only the decision above the gates, and route every pre-dispatch refusal through one seam. What is implemented here:
formShaped/formNavigation/handleNoJSare resolved before the id lookup, and a singlerefuse(response, vary)closure either hands the refusal tohandleNoJS(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 —303where404/400/413/403used to be, for browser form navigations only.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.
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.
refuseCommitteddisappears, thefinalizeTransportResponse(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 belowcreateEvent. It does not add a second contract; it makes the one already written true.Two things are genuinely the maintainer's call, not mine:
303s where they saw404/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.refusalErrorgives the version-skew case a message the user can act on and everything else a bareThe 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):Against
f0f7531ball five go red on the destination assertion —AssertionError: origin check refused — status 403: expected 403 to be 303— while therancounters stay at0, which is what pins the "nothing committed, so the bounce is safe" half of the argument rather than assuming it.