Skip to content

fix(web): stop sending undefined as null before a trailing string - #3622

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/server-fn-undefined-args
Sep 24, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/server-fn-undefined-args

Conversation

@brenelz

@brenelz brenelz commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A server-function call whose last argument is a string sent an earlier undefined argument to the server as null, so default parameters and === undefined checks broke:

const impl = async (id: number, limit = 10, q = "") => `id=${id} limit=${limit} q=${q}`;
await impl(1, undefined, "milk");   // "id=1 limit=10 q=milk"
await search(1, undefined, "milk"); // "id=1 limit=null q=milk"

initializeResponse has a fast path for bound calls that end in a natural HTTP encoding (action.with(id) posting a form): the leading arguments go in ?args= as JSON and the last argument is the body. It maps undefined to null before the isJSONSafe check, and getHeadersAndBody counts a plain string as a natural encoding. A string call can only reach that path when a leading argument is not JSON-safe, so in practice it got there only because of the undefined mapping. This happened with enableRichArguments() enabled too.

The fix keeps strings out of that fast path, so an undefined before a string goes to the codec. With rich arguments the function now receives undefined and the default applies. Without them the call throws the same "sent as JSON by default" error that search(1, undefined) already throws. Bound form actions keep their current shape: before a FormData, URLSearchParams or File body, undefined is still sent as null, which matches the no-JS action url the router renders with hashKey(args). The existing server-functions-body-formats spec pins that behaviour.

Dropping the mapping altogether was not chosen: without rich arguments every bound form action with an undefined binding would start throwing. Limiting the fast path strictly to FormData, URLSearchParams and File was not chosen either: a trailing Blob would then go to the codec, which cannot serialize a Blob, so f("folder", blob) would break.

The docs paragraph on argument encoding in documentation/solid-2.0/10-server-functions.md now lists a top-level undefined among the values that need rich arguments, and spells out the one exception (a bound call whose trailing argument is a body-like object). The ruling is recorded in test/server/server-function-matrix/MATRIX.md.

Open question resolved: routing undefined to the codec is correct. Under the default config it now hits the pre-existing "sent as JSON by default" throw, the same as any other undefined argument; the bound fast path was the exception, not the rule.

Public API changes

  • Documented behavior change (default codec): fn(1, undefined, "str") previously resolved with the server seeing null for the undefined argument. It now rejects client-side, before any request is sent, with the existing error:

    Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() (from "@solidjs/web/server-functions/rich-args") once at startup to send Dates, Maps, Sets, typed arrays, etc. through the codec — or pass a single Blob/FormData/File argument, which has a native HTTP encoding.

    This is the same error fn(1, undefined) already threw; only the trailing-string case is new.

  • Rich codec (enableRichArguments()): fn(1, undefined, "str") previously delivered null; it now delivers a real undefined, so default parameters apply. No error path changes.

  • Unchanged: bound calls whose trailing argument is FormData, URLSearchParams, File, Blob, ArrayBuffer or Uint8Array keep the ?args=[...,null] wire shape.

  • No exports, options, or signatures added or removed.

How did you test this change?

The new packages/web/test/server/server-functions-undefined-arguments.spec.tsx covers:

  • In the default config, search(1, undefined, "milk") rejects and the function never runs. Before the fix it resolved with limit=null.
  • With rich arguments enabled, search(1, undefined, "milk") returns id=1 limit=10 q=milk through the codec. Before the fix it returned limit=null.
  • search(1, undefined) still rejects.
  • search(1, 5, "milk") is still sent as the plain JSON body [1,5,"milk"].
  • Bound calls ending in FormData, URLSearchParams and File keep ?args=[...,null] and their body format tag.

Against the unfixed source, 2 of the tests failed, both with limit=null. With the fix, all 7 pass.

From packages/web, after pnpm build:

npx vitest run                                    # 101 files, 898 passed
npx vitest run --config vite.config.server.mjs    # 111 files, 1119 passed, 2 skipped
npx vitest run --config vite.config.hydrate.mjs   # 37 files, 243 passed, 1 expected fail
pnpm run test-types                               # clean

scripts/size: npx size-limit shows all 10 entries passing with no size change (the frames entry does not retain initializeResponse). A bundle importing createServerReference grows by 24 B minified (21 B gzip, 26 B brotli).

@changeset-bot

changeset-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cc4a898

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing brenelz:fix/server-fn-undefined-args (cc4a898) with next (4b62bc2)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid force-pushed the fix/server-fn-undefined-args branch from c7b11b6 to cc4a898 Compare September 24, 2026 01:09
@ryansolid
ryansolid merged commit fb35efb into solidjs:next Sep 24, 2026
6 checks passed
@ryansolid

Copy link
Copy Markdown
Member

Verified locally on a rebase onto next: the fix is exactly the trailing-string guard on the bound ?args= fast path, FormData/URLSearchParams/File controls pin the unchanged bound shape, and all web suites pass under both compilers plus type tests and size-limit.

Ruling on the open question: routing undefined to the codec is correct. Under the default config it hits the pre-existing "sent as JSON by default" throw, the same as any other undefined argument — the fast path was the exception, not the rule. Recorded in MATRIX.md.

Thanks, Brenley.

— Claude via Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants