Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-fn-undefined-before-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Server-function calls that end in a string no longer turn an earlier `undefined` argument into `null`. `search(1, undefined, "milk")` took the bound form-post path, which moves the leading arguments into `?args=` as JSON, so the function ran with `limit = null` and its default parameter never applied. A trailing string now goes through the codec like any other argument list that JSON cannot carry: with `enableRichArguments()` the function receives `undefined`, and without it the call throws the same "sent as JSON by default" error as `search(1, undefined)`. Bound form actions (`action.with(id)` posting FormData, URLSearchParams or a File) keep the `?args=` shape and still send `undefined` as `null`, which matches the no-JS action url.
2 changes: 1 addition & 1 deletion documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o

The package resolves to a client entry in the browser and a server entry elsewhere.

**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and `<link rel="preload">`). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and `<link rel="preload">`). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles, a top-level `undefined` — except in a bound call whose trailing argument is a body-like object (FormData, URLSearchParams, File, Blob, ...), where the leading arguments ride `?args=` and `undefined` is sent as `null` as in the action url; a trailing string is not a body in this sense, so `fn(1, undefined, "str")` goes through the codec) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)

**Server:** `configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:

Expand Down
6 changes: 4 additions & 2 deletions packages/web/server-functions/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,10 +601,12 @@ async function initializeResponse(base, id, options, args, meta) {
// handler prepends url arguments before natural-encoding bodies) and the
// trailing argument IS the body. The same wire shape the no-JS fallback
// produces, so bound form actions need no codec. `undefined` coerces to
// null exactly as it does in a rendered action url (JSON has none).
// null as it does in a router-rendered action url (JSON has none).
// Strings are excluded so an `undefined` before one reaches the codec.
if (args.length > 1) {
try {
const trailing = getHeadersAndBody(args[args.length - 1]);
const last = args[args.length - 1];
const trailing = typeof last !== "string" && getHeadersAndBody(last);
const leading = args.slice(0, -1).map(arg => (arg === undefined ? null : arg));
if (trailing && isJSONSafe(leading)) {
const target =
Expand Down
25 changes: 25 additions & 0 deletions packages/web/test/server/server-function-matrix/MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Status legend: **pass** (ordinary green guard) · **audit** (reported by
| Body-format tags are recognized before decoding | **pass** | n/a | **pass** | `server-functions-body-formats`; #3245 covers the client half |
| Unsafe own keys are removed at every untrusted decode boundary | **pass** | n/a | #3233 **audit** | `server-functions-proto-keys`, `server-functions-open-gaps` |
| Decoded promises are always owned, even when their container is abandoned | n/a | n/a | #3232 **audit** | new focused spec required |
| The bound `?args=` fast path applies only when the trailing argument is a body-like object; an `undefined` before a trailing string rides the codec | **pass** | n/a | **pass** | `server-functions-undefined-arguments`, `server-functions-body-formats`; #3622 **ruling** |

## Result graph and request scope

Expand Down Expand Up @@ -314,6 +315,30 @@ Pinned in `server-functions-dev-rebind-grant` (carry, origin gate on a
carried id, re-declaration upgrade, stale re-declaration, chained rebinds,
stale-grant refusal, production revocation).

## Ruling — `undefined` before a trailing string (#3622, 2026-09-23)

The bound fast path (`action.with(id)` posting a body: leading arguments in
`?args=` as JSON, `undefined` coerced to `null` as in the router-rendered
action url) admitted a trailing plain string, because `getHeadersAndBody`
gives strings a natural encoding. A string call reaches that path only when
the JSON fast path refused the list — in practice only over a leading
`undefined` — so `search(1, undefined, "milk")` ran with `limit = null` and
the default parameter never applied, rich arguments or not. Resolved:

1. **The fast path is for body-like objects only** — FormData,
URLSearchParams, File, Blob, ArrayBuffer, Uint8Array. A trailing string is
excluded; those bound calls keep their `?args=[...,null]` shape.
2. **`undefined` otherwise rides the codec, like any other `undefined`
argument.** With `enableRichArguments()` the function receives a real
`undefined`; under the default config the call rejects with the existing
"sent as JSON by default" error, exactly as `search(1, undefined)` does.
The fast path was the exception, not the rule; a default-config app that
observed `null` was relying on the defect.

Pinned in `server-functions-undefined-arguments` (default-config refusal,
rich-argument delivery, plain-JSON string call, FormData/URLSearchParams/File
controls).

## Extraction and merge discipline

- A red test demonstrates current behavior; it becomes an ordinary guard only
Expand Down
194 changes: 194 additions & 0 deletions packages/web/test/server/server-functions-undefined-arguments.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Runs against the built bundles (server-functions/dist/*, see vite.config.server.mjs).
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
handleServerFunctionRequest,
registerServerFunction
} from "@solidjs/web/server-functions/server";
import {
configureServerFunctionsClient,
createServerReference,
getServerFunctionsCodec,
serializeString
} from "@solidjs/web/server-functions/client";

const RequestContext = Symbol.for("solid.RequestContext");
const BODY_FORMAT_HEADER = "X-Server-Function-Format";
const SERIALIZED_FORMAT = "0";
const FORM_DATA_FORMAT = "2";
const URL_PARAMS_FORMAT = "3";
const FILE_FORMAT = "5";
const JSON_FORMAT = "8";

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

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

function connectTransport() {
const original = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const request =
input instanceof Request
? input
: new Request(new URL(input.toString(), "https://app.example"), init);
request.headers.set("Sec-Fetch-Site", "same-origin");
requests.push(request.clone());
return handleServerFunctionRequest(request);
}) as typeof fetch;
return {
requests,
restore() {
globalThis.fetch = original;
}
};
}

function registerSearch(id: string) {
let ran = 0;
const impl = async (id: number, limit = 10, q = "") => {
ran++;
return `id=${id} limit=${limit} q=${q}`;
};
registerServerFunction(id, impl);
return {
search: createServerReference(id) as unknown as typeof impl,
runs: () => ran
};
}

describe("without rich arguments", () => {
it("refuses an undefined before a trailing string instead of sending null", async () => {
const { search, runs } = registerSearch("undefined-args-default-string");
const transport = connectTransport();
try {
await expect(search(1, undefined, "milk")).rejects.toThrow(/sent as JSON by default/);
expect(runs()).toBe(0);
expect(transport.requests).toHaveLength(0);
} finally {
transport.restore();
}
});

it("refuses a trailing undefined the same way", async () => {
const { search, runs } = registerSearch("undefined-args-default-trailing");
const transport = connectTransport();
try {
await expect(search(1, undefined)).rejects.toThrow(/sent as JSON by default/);
expect(runs()).toBe(0);
} finally {
transport.restore();
}
});

it("sends a string call without undefined as a plain JSON body", async () => {
const { search } = registerSearch("undefined-args-default-json");
const transport = connectTransport();
try {
expect(await search(1, 5, "milk")).toBe("id=1 limit=5 q=milk");
const [request] = transport.requests;
expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(JSON_FORMAT);
expect(await request.text()).toBe('[1,5,"milk"]');
expect(new URL(request.url).searchParams.has("args")).toBe(false);
} finally {
transport.restore();
}
});

it("keeps a bound form action's wire shape, undefined included", async () => {
let seen: unknown[] = [];
registerServerFunction("undefined-args-bound-form", async (...args: unknown[]) => {
seen = args;
return "ok";
});
const transport = connectTransport();
try {
const form = new FormData();
form.append("title", "milk");
expect(
await createServerReference("undefined-args-bound-form")("list", undefined, form)
).toBe("ok");
expect(seen.slice(0, 2)).toEqual(["list", null]);
expect((seen[2] as FormData).get("title")).toBe("milk");
const [request] = transport.requests;
expect(new URL(request.url).searchParams.get("args")).toBe('["list",null]');
expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(FORM_DATA_FORMAT);
} finally {
transport.restore();
}
});

it("keeps the bound shape for a trailing URLSearchParams", async () => {
let seen: unknown[] = [];
registerServerFunction("undefined-args-bound-params", async (...args: unknown[]) => {
seen = args;
return "ok";
});
const transport = connectTransport();
try {
await createServerReference("undefined-args-bound-params")(
7,
undefined,
new URLSearchParams("q=milk")
);
expect(seen.slice(0, 2)).toEqual([7, null]);
expect(seen[2]).toBeInstanceOf(URLSearchParams);
expect((seen[2] as URLSearchParams).get("q")).toBe("milk");
const [request] = transport.requests;
expect(new URL(request.url).searchParams.get("args")).toBe("[7,null]");
expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(URL_PARAMS_FORMAT);
} finally {
transport.restore();
}
});

it("keeps the bound shape for a trailing File", async () => {
let seen: unknown[] = [];
registerServerFunction("undefined-args-bound-file", async (...args: unknown[]) => {
seen = args;
return "ok";
});
const transport = connectTransport();
try {
await createServerReference("undefined-args-bound-file")(
"/inbox",
undefined,
new File(["scan"], "scan.pdf", { type: "application/pdf" })
);
expect(seen.slice(0, 2)).toEqual(["/inbox", null]);
expect(seen[2]).toBeInstanceOf(File);
expect((seen[2] as File).name).toBe("scan.pdf");
const [request] = transport.requests;
expect(new URL(request.url).searchParams.get("args")).toBe('["/inbox",null]');
expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(FILE_FORMAT);
} finally {
transport.restore();
}
});
});

// Last in the file: the client config has no way to remove serializeArgs.
describe("with rich arguments", () => {
beforeAll(() => {
configureServerFunctionsClient({
serializeArgs: args => serializeString(args, getServerFunctionsCodec())
});
});

it("delivers an undefined before a trailing string, so the default applies", async () => {
const { search } = registerSearch("undefined-args-rich-string");
const transport = connectTransport();
try {
expect(await search(1, undefined, "milk")).toBe("id=1 limit=10 q=milk");
const [request] = transport.requests;
expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(SERIALIZED_FORMAT);
expect(new URL(request.url).searchParams.has("args")).toBe(false);
} finally {
transport.restore();
}
});
});
Loading