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 #10380 — JSON.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.
Summary
Request.json()andResponse.json()return an object whose keys are alphabetically sorted instead of in the JSON document's order..text()on the same body, and a plainJSON.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.stringifymust observe that order (only integer-like keys are hoisted and sorted). bun, node andJSON.parsein perry itself all agree.Reproducer
Measured (perry @ v0.5.1579 + #10356 + #10380,
--platform bun, vs bun 1.3.14)Request.text(){"title":"t","permission":[],"zeta":1}Request.json(){"title":"t","permission":[],"zeta":1}{"permission":[],"title":"t","zeta":1}Object.keys(await req.json())["title","permission","zeta"]["permission","title","zeta"]Request.text()on a Request built from a RequestRequest.json()on a Request built from a RequestResponse.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:
JSON.stringify(JSON.parse(src))JSON.stringify({literal})o.b=1; o.a=2; o.c=3{"b":1,"a":2,"c":3}Object.keys(JSON.parse(src)){"10":…,"2":…,"x":…}["2","10","x"]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
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 #10380 —
JSON.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.