Skip to content

-0 takes the JSON fast path and arrives as +0 #3253

Description

@frenzzy

Summary

isJSONSafe (packages/web/server-functions/src/shared.ts) is the guard both peers use to decide whether a value can ride the plain-JSON fast path or must ride the codec. Its number branch admits every Number.isFinite(v), but JSON.stringify(-0) is "0", so a signed zero takes the fast path and arrives on the other side as +0 — status 200, function runs, sign silently gone. NaN and the infinities, the other numbers JSON.stringify cannot spell, are already refused by that same branch and ride the codec, which encodes -0 exactly; so this is not "JSON cannot carry it", it is the one leg of the finite-number check nobody mirrored. Because both peers consult the one guard, this shows up in both directions — a -0 argument and a -0 result — and inside objects, where the same field keeps its sign only when some other value in the result happens to drag the whole payload onto the codec.

Reproduction

Against f0f7531b (@solidjs/web@2.0.0-rc.6), built bundles, no browser and no framework integration — the client transport's fetch is pointed straight at the server handler. Saved as packages/web/nz-repro.mjs, run with node nz-repro.mjs.

import { AsyncLocalStorage } from "node:async_hooks";
import {
  handleServerFunctionRequest,
  registerServerFunction
} from "@solidjs/web/server-functions/server";
import {
  configureServerFunctionsClient,
  createServerReference,
  getServerFunctionsCodec,
  serializeString
} from "@solidjs/web/server-functions/client";

globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const FORMAT = "X-Server-Function-Format";

// rich arguments on: the codec IS available for arguments, so the only
// question is which encoding the guard picks.
configureServerFunctionsClient({
  serializeArgs: args => serializeString(args, getServerFunctionsCodec())
});

const requests = [];
globalThis.fetch = (input, init) => {
  const request =
    input instanceof Request ? input : new Request(new URL(String(input), "https://app.example"), init);
  request.headers.set("Sec-Fetch-Site", "same-origin");
  requests.push(request.clone());
  return handleServerFunctionRequest(request);
};

const show = n => (Object.is(n, -0) ? "-0" : String(n));
const seen = {};

registerServerFunction("arg-nz", async n => ((seen.nz = n), "ok"));
registerServerFunction("arg-nan", async n => ((seen.nan = n), "ok"));
registerServerFunction("res-nz", async () => -0);
registerServerFunction("res-nan", async () => NaN);
registerServerFunction("res-obj", async () => ({ delta: -0 }));
registerServerFunction("res-obj-nan", async () => ({ delta: -0, other: NaN }));

async function trip(label, fn) {
  const before = requests.length;
  const out = await fn();
  const req = requests[before];
  return { out, body: await req.clone().text(), format: req.headers.get(FORMAT), label };
}

const a1 = await trip("arg NaN  (control)", () => createServerReference("arg-nan")(NaN));
console.log(`ARG   NaN  (control) sent ${JSON.stringify(a1.body)} format=${a1.format} -> function got ${show(seen.nan)}`);
const a2 = await trip("arg -0", () => createServerReference("arg-nz")(-0));
console.log(`ARG   -0             sent ${JSON.stringify(a2.body)} format=${a2.format} -> function got ${show(seen.nz)}  (1/x = ${1 / seen.nz})`);

const r1 = await createServerReference("res-nan")();
console.log(`RES   NaN  (control) call resolved with ${show(r1)}`);
const r2 = await createServerReference("res-nz")();
console.log(`RES   -0             call resolved with ${show(r2)}  (1/x = ${1 / r2})`);

const o1 = await createServerReference("res-obj-nan")();
console.log(`RES   { delta: -0, other: NaN } (control) delta = ${show(o1.delta)}`);
const o2 = await createServerReference("res-obj")();
console.log(`RES   { delta: -0 }                       delta = ${show(o2.delta)}`);

Measured output on f0f7531b:

ARG   NaN  (control) sent ";0x00000027;{\"t\":9,\"i\":0,\"a\":[{\"t\":2,\"s\":7}],\"o\":0}" format=0 -> function got NaN
ARG   -0             sent "[0]" format=8 -> function got 0  (1/x = Infinity)
RES   NaN  (control) call resolved with NaN
RES   -0             call resolved with 0  (1/x = Infinity)
RES   { delta: -0, other: NaN } (control) delta = -0
RES   { delta: -0 }                       delta = 0

format=0 is BodyFormat.Serialized (the codec), format=8 is BodyFormat.Json (the fast path). Three things are visible in those six lines:

  • The NaN control rows behave: refused by the guard, carried by the codec, intact on arrival. That is the contract the -0 rows break.
  • The -0 argument was put on the wire as the literal [0] under the JSON tag — the sign is gone before the request leaves the client, so nothing downstream can recover it.
  • The last two rows are the same object shape with the same delta. The sign survives only in the row where a NaN sits beside it and drags the whole result onto the codec. The codec carries -0 exactly; only the road chosen for it is wrong.

Same output on the fixed tree, for contrast:

ARG   NaN  (control) sent ";0x00000027;{\"t\":9,\"i\":0,\"a\":[{\"t\":2,\"s\":7}],\"o\":0}" format=0 -> function got NaN
ARG   -0             sent ";0x00000027;{\"t\":9,\"i\":0,\"a\":[{\"t\":2,\"s\":4}],\"o\":0}" format=0 -> function got -0  (1/x = -Infinity)
RES   NaN  (control) call resolved with NaN
RES   -0             call resolved with -0  (1/x = -Infinity)
RES   { delta: -0, other: NaN } (control) delta = -0
RES   { delta: -0 }                       delta = -0

Where

One line does it, in packages/web/server-functions/src/shared.ts at f0f7531b:

  • shared.ts:772if (!Number.isFinite(v)) return false;, the number branch of isJSONSafe. Number.isFinite(-0) is true, so -0 is admitted to the fast path.
  • shared.ts:732 and shared.ts:743 — the overload and implementation doc blocks, both of which say "JSON primitives (finite numbers only)" and list NaN among the values that need the codec. They document the rule the code implements; both need -0 named beside NaN if the branch changes.

Consumers of the guard, for scope — the defect reaches every one of them through the single function, which is why arguments and results fail identically: client.ts:299, client.ts:576, client.ts:607, client.ts:966, server.ts:2428, server.ts:2578.

Provenance:

$ git log --oneline -S 'if (!Number.isFinite(v)) return false;' -- packages/web/server-functions/src/shared.ts
71821959 Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders.

$ git log --oneline -S 'if (!Number.isFinite(v)) return false;' f0f7531b
89a0531c Absorb expressions into Solid and collapse the rxcore seam.

The line entered the tree in 89a0531 (2026-08-25) as packages/web/src/server-functions/shared.js:324 and moved to its current path unchanged in 7182195 (the TypeScript migration; a pure rename, which is why -S over the whole tree names only the first). No later commit created it. It has, however, survived a rework of its own function: 2320bc9 (fix: guard getter-backed and Map-key channels in server function results (#3176)) rewrote the object branch of isJSONSafe a dozen lines below to read through descriptors, and left the number branch as it found it.

Why it matters

The realistic path to harm is narrow and worth stating precisely rather than inflating.

Nothing crashes and nothing leaks. The call succeeds, the status is 200, and the only observable difference is the sign of a zero — visible through Object.is, 1 / x, Math.sign on the reciprocal, or String formatting that preserves it. An application only notices if a signed zero is genuinely one of its values and the sign carries meaning: a delta that decreased to nothing rather than sat at nothing, a coordinate approached from the negative side, a rate or bearing read back as 1 / x for its direction, a physics or charting value where -0 marks which side of an axis a quantity came from. Signed zeros are easy to produce by accident — Math.round(-0.2), Math.min(0, -0), -x where x is 0, JSON.parse("-0") — but in most applications nothing downstream ever asks about the sign, and the corruption is unobservable.

What makes it worth fixing anyway is not the size of the blast radius but that the guard's whole job is to answer this question honestly, and here it answers wrong in the one direction that is silent. The neighbouring branches of the same function refuse exactly this class of quiet corruption on much rarer values: a bare undefined (stringify drops it), a sparse array hole (stringify writes null), a getter-backed field (#3176). A signed zero is more likely to appear in ordinary numeric code than any of those. The asymmetry in the last two repro rows is the sharpest form of it: the same data with the same shape round-trips faithfully or lossily depending on what else is in the payload, which is not a rule anyone can reason about or test against.

No unusual integration is required — the repro is a plain Node script, no browser, no adapter, no rich-arguments opt-in on the result direction (the handler always holds both halves of the codec). But equally: nobody is going to see a stack trace or a failed request from this. It is a fidelity bug in a rarely-load-bearing value.

Options

  1. Refuse -0 in the guard's number branch — one condition, if (!Number.isFinite(v) || Object.is(v, -0)) return false;, plus -0 named beside NaN in the two doc blocks. Both directions and both peers are fixed by the single edit, because they all consult this one function, and the codec already encodes -0 exactly (seroval has a constant for it), so no wire format changes. Costs: any payload containing a -0 anywhere now rides the codec, which is a larger body and a slower encode than JSON.stringify — the repro shows [0] (3 bytes) becoming a codec envelope. And there is a knock-on on the argument leg, below.
  2. Document the flattening and leave the runtime alone — state in the isJSONSafe doc and the server-functions docs that -0 arrives as +0 on the fast path. Zero runtime cost, no payload growth, no behaviour change for anyone. It leaves the guard's contract ("survives a JSON.stringify round trip faithfully") untrue for one value, and leaves the payload-dependent asymmetry in place.
  3. Preserve the sign on the fast path — keep -0 JSON-safe and re-mint the sign on decode. JSON has no room for the marker, so this means a paired custom stringify/parse on the fast path, which is the one thing the fast path exists to avoid. Not worth it.
  4. Fix results only (server.ts encodeResult) and leave arguments on the fast path — avoids the argument-leg knock-on, but makes the same value round-trip with different fidelity depending on direction, and the guard is a single shared function whose callers do not distinguish. Splitting them means splitting the contract.

Recommendation: option 1. It is the minimal edit that makes the guard's stated contract true, it reuses machinery already in place rather than adding any, and it removes a special case (-0) from the fast path rather than adding one — the finite-number check becomes "numbers JSON.stringify spells faithfully", which is the rule the function already claims to implement.

The knock-on, and where the judgement sits. Once -0 is not JSON-safe, an argument -0 needs the codec, so with rich arguments not enabled the client refuses the call loudly instead of flattening it quietly — exactly as NaN already does. Measured, same harness, no serializeArgs configured:

# f0f7531b
rich args OFF, NaN: Error: Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() ...
rich args OFF, -0: no error, function got 0
rich args OFF, new Date(0): Error: Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() ...

# fixed
rich args OFF, NaN: Error: Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() ...
rich args OFF, -0: Error: Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() ...
rich args OFF, new Date(0): Error: Server function arguments are sent as JSON by default and these arguments are not JSON-serializable. Call enableRichArguments() ...

So call(-0) goes from silently wrong to loudly refused in a default app. That is a behaviour change on a value ordinary numeric code can produce by accident, and whether the trade is right — a loud refusal and a codec-sized payload, versus a documented silent flattening (option 2) — is a maintainer's call about the fast path's contract, not something this report should decide. The same call decides whether the fix belongs in the runtime guard at all or only in the docs.

Regression test

packages/web/test/server/server-functions-negative-zero.spec.tsx, three tests, each pairing its case with the NaN control that already behaves, so a reader can see the two halves of the same branch disagree:

describe("a signed zero on the wire", () => {
  it("reaches the server function as -0, the way NaN already does", async () => {
    const seen: Record<string, unknown> = {};
    registerServerFunction("negative-zero-arg", async (n: number) => {
      seen.arg = n;
      return "ok";
    });
    registerServerFunction("nan-arg", async (n: number) => {
      seen.control = n;
      return "ok";
    });
    const transport = connectTransport();
    try {
      // the control: NaN is refused by the fast path, rides the codec, and
      // arrives intact — the behaviour -0 is measured against
      await createServerReference("nan-arg")(NaN);
      expect(Number.isNaN(seen.control), `NaN control arrived as ${String(seen.control)}`).toBe(
        true
      );

      await createServerReference("negative-zero-arg")(-0);
      expect(
        Object.is(seen.arg, -0),
        `the function was handed ${Object.is(seen.arg, -0) ? "-0" : String(seen.arg)}` +
          ` (1/x = ${1 / (seen.arg as number)}), sent as ${await transport.requests[1].clone().text()}` +
          ` under format ${transport.requests[1].headers.get(BODY_FORMAT_HEADER)}`
      ).toBe(true);
    } finally {
      transport.restore();
    }
  });

  it("comes back from the server function as -0, the way NaN already does", async () => {
    registerServerFunction("negative-zero-result", async () => -0);
    registerServerFunction("nan-result", async () => NaN);
    const transport = connectTransport();
    try {
      const control = await createServerReference("nan-result")();
      expect(Number.isNaN(control), `NaN control came back as ${String(control)}`).toBe(true);

      const result = await createServerReference("negative-zero-result")();
      expect(
        Object.is(result, -0),
        `the call resolved with ${Object.is(result, -0) ? "-0" : String(result)}` +
          ` (1/x = ${1 / (result as number)})`
      ).toBe(true);
    } finally {
      transport.restore();
    }
  });

  it("keeps its sign inside an otherwise JSON-safe object result", async () => {
    // the whole object rides one encoding, so a single unsafe value drags
    // the rest onto the codec: with NaN alongside it the -0 survives today,
    // and without it the same field is flattened. Same data, same shape —
    // only the company it keeps decides whether the sign lives.
    registerServerFunction("negative-zero-field", async () => ({ delta: -0 }));
    registerServerFunction("negative-zero-field-with-nan", async () => ({ delta: -0, other: NaN }));
    const transport = connectTransport();
    try {
      const dragged: any = await createServerReference("negative-zero-field-with-nan")();
      expect(
        Object.is(dragged.delta, -0),
        `the codec road lost the sign too: delta=${String(dragged.delta)}`
      ).toBe(true);

      const plain: any = await createServerReference("negative-zero-field")();
      expect(
        Object.is(plain.delta, -0),
        `the same field, alone, came back as ${String(plain.delta)}`
      ).toBe(true);
    } finally {
      transport.restore();
    }
  });
});

Like the other server-function specs it runs against the built bundles. It goes red on f0f7531b — 3 failed / 3, the first with the function was handed 0 (1/x = Infinity), sent as [0] under format 8, the last with the same field, alone, came back as 0 while its NaN-accompanied twin passes — and green with the one-line change to the number branch. Revert only that condition and these three go red while every other spec stays green, which is what marks it a defect of its own rather than part of the neighbouring guard-walk change in the same function.

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