Skip to content

Request.json() / Response.json() return keys alphabetically sorted instead of in document order #10392

Description

@proggeramlug

Summary

Request.json() and Response.json() return an object whose keys are alphabetically sorted instead of in the JSON document's order. .text() on the same body, and a plain JSON.parse() of the same string, both preserve order correctly — so the divergence is specific to the body-consuming .json() helpers.

Per ECMA-262, a JSON object's own string keys are created in document order and Object.keys/JSON.stringify must observe that order (only integer-like keys are hoisted and sorted). bun, node and JSON.parse in perry itself all agree.

Reproducer

// @ts-nocheck
const body = '{"title":"t","permission":[],"zeta":1}'
const ta = async (n, f) => { try { console.log(n, JSON.stringify(await f())) } catch (e) { console.log(n, "THREW " + e.message) } }

await ta("1 plain .text()", () => new Request("http://e.com/", { method: "POST", body }).text())
await ta("2 plain .json()", () => new Request("http://e.com/", { method: "POST", body }).json())
await ta("3 plain keys",    async () => Object.keys(await new Request("http://e.com/", { method: "POST", body }).json()))
await ta("6 response .json()", () => new Response(body).json())

Measured (perry @ v0.5.1579 + #10356 + #10380, --platform bun, vs bun 1.3.14)

# cell bun perry
1 Request.text() {"title":"t","permission":[],"zeta":1} same ok
2 Request.json() {"title":"t","permission":[],"zeta":1} {"permission":[],"title":"t","zeta":1}
3 Object.keys(await req.json()) ["title","permission","zeta"] ["permission","title","zeta"]
4 Request.text() on a Request built from a Request order preserved same ok
5 Request.json() on a Request built from a Request order preserved sorted
6 Response.json() {"title":"t","permission":[],"zeta":1} {"permission":[],"title":"t","zeta":1}

permission < title < zeta — the perry output is exactly alphabetical, which is the tell.

Not a general JSON bug

A separate fixture confirms the ordinary paths are all correct, so this is isolated to the fetch body helpers:

cell bun perry
JSON.stringify(JSON.parse(src)) order preserved same ok
JSON.stringify({literal}) order preserved same ok
insertion order o.b=1; o.a=2; o.c=3 {"b":1,"a":2,"c":3} same ok
Object.keys(JSON.parse(src)) document order same ok
numeric-key hoisting {"10":…,"2":…,"x":…} ["2","10","x"] same ok

That last row is worth noting: perry already implements the correct integer-key hoisting rule for JSON.parse, so the machinery to preserve order exists — .json() simply is not using it.

Why it matters

Any HTTP JSON round-trip through perry silently reorders object keys. That breaks

  • signature/digest schemes over a canonical serialization of a received body,
  • snapshot and golden-file tests that compare serialized payloads,
  • anything that re-serializes a received body and compares it to the original bytes.

It is also a silent corruption rather than a failure: the values are all correct, so nothing throws and it surfaces only as a mismatched string far from the cause.

How I found it

It failed one cell of a regression test for #10380JSON.stringify(await request.json()) — while the neighbouring .text() cell passed. The asymmetry between the two on the same body is what identified it as a .json() bug rather than a body-inheritance bug.

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