From f6805aa959828ecd3f74904addd8d3651e185a35 Mon Sep 17 00:00:00 2001 From: ayushdwivedi-stack Date: Fri, 9 Oct 2026 15:17:07 +0530 Subject: [PATCH] perf(query): avoid intermediate array allocations in stringifyQuery (#332) --- src/query.ts | 38 +++++++++++----- test/query.test.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/query.ts b/src/query.ts index 6fbcaaa1..721f3711 100644 --- a/src/query.ts +++ b/src/query.ts @@ -53,7 +53,13 @@ export function parseQuery( if (parametersString[0] === "?") { parametersString = parametersString.slice(1); } + if (!parametersString) { + return object as T; + } for (const parameter of parametersString.split("&")) { + if (!parameter) { + continue; + } const s = parameter.match(/([^=]+)=?(.*)/) || []; if (s.length < 2) { continue; @@ -103,12 +109,15 @@ export function encodeQueryItem( } if (Array.isArray(value)) { - return value - .map( - (_value: QueryValue) => - `${encodeQueryKey(key)}=${encodeQueryValue(_value)}`, - ) - .join("&"); + if (value.length === 0) { + return ""; + } + const encodedKey = encodeQueryKey(key); + let res = ""; + for (const val of value) { + res += (res ? "&" : "") + `${encodedKey}=${encodeQueryValue(val)}`; + } + return res; } return `${encodeQueryKey(key)}=${encodeQueryValue(value)}`; @@ -130,9 +139,16 @@ export function encodeQueryItem( * @group Query_utils */ export function stringifyQuery(query: QueryObject): string { - return Object.keys(query) - .filter((k) => query[k] !== undefined) - .map((k) => encodeQueryItem(k, query[k])) - .filter(Boolean) - .join("&"); + let result = ""; + for (const key of Object.keys(query)) { + const value = query[key]; + if (value === undefined) { + continue; + } + const item = encodeQueryItem(key, value); + if (item) { + result += (result ? "&" : "") + item; + } + } + return result; } diff --git a/test/query.test.ts b/test/query.test.ts index 8ef15e44..5ad7dfee 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "vitest"; -import { filterQuery, getQuery, withQuery } from "../src"; +import { + filterQuery, + getQuery, + withQuery, + stringifyQuery, + encodeQueryItem, + parseQuery, +} from "../src"; describe("withQuery", () => { const tests = [ @@ -109,3 +116,104 @@ describe("getQuery", () => { }); } }); + +describe("stringifyQuery", () => { + test("empty query", () => { + expect(stringifyQuery({})).toBe(""); + }); + + test("undefined values are omitted", () => { + expect(stringifyQuery({ foo: undefined, bar: undefined })).toBe(""); + expect(stringifyQuery({ foo: "1", bar: undefined, baz: "2" })).toBe( + "foo=1&baz=2", + ); + }); + + test("primitives and boolean values", () => { + expect( + stringifyQuery({ + num: 42, + zero: 0, + truthy: true, + falsy: false, + }), + ).toBe("num=42&zero=0&truthy=true&falsy=false"); + }); + + test("null and empty string values", () => { + expect( + stringifyQuery({ + empty: "", + nul: null, + }), + ).toBe("empty&nul"); + }); + + test("array values", () => { + expect(stringifyQuery({ tags: ["javascript", "web", "dev"] })).toBe( + "tags=javascript&tags=web&tags=dev", + ); + expect(stringifyQuery({ empty: [] })).toBe(""); + expect(stringifyQuery({ a: "1", empty: [], b: "2" })).toBe("a=1&b=2"); + }); + + test("encoded keys and values", () => { + expect( + stringifyQuery({ + "my key": "val & val", + }), + ).toBe("my+key=val+%26+val"); + }); + + test("does not iterate prototype properties", () => { + const proto = { inherited: "proto-value" }; + const query = Object.create(proto); + query.own = "own-value"; + expect(stringifyQuery(query)).toBe("own=own-value"); + }); +}); + +describe("encodeQueryItem", () => { + test("handles primitives", () => { + expect(encodeQueryItem("num", 100)).toBe("num=100"); + expect(encodeQueryItem("bool", true)).toBe("bool=true"); + expect(encodeQueryItem("bool", false)).toBe("bool=false"); + }); + + test("handles empty and null values", () => { + expect(encodeQueryItem("key", "")).toBe("key"); + expect(encodeQueryItem("key", null)).toBe("key"); + }); + + test("handles arrays efficiently", () => { + expect(encodeQueryItem("tags", [])).toBe(""); + expect(encodeQueryItem("tags", ["one"])).toBe("tags=one"); + expect(encodeQueryItem("tags", ["one", "two"])).toBe("tags=one&tags=two"); + expect(encodeQueryItem("a b", ["1", "2"])).toBe("a+b=1&a+b=2"); + }); +}); + +describe("parseQuery", () => { + test("empty and leading question mark", () => { + expect(parseQuery("")).toEqual({}); + expect(parseQuery("?")).toEqual({}); + expect(parseQuery("?foo=bar")).toEqual({ foo: "bar" }); + }); + + test("skips empty segments and handles keys without values", () => { + expect(parseQuery("foo&&bar&")).toEqual({ foo: "", bar: "" }); + expect(parseQuery("a=1&&b=2")).toEqual({ a: "1", b: "2" }); + }); + + test("handles multiple and duplicate keys", () => { + expect(parseQuery("tag=a&tag=b&tag=c")).toEqual({ + tag: ["a", "b", "c"], + }); + }); + + test("ignores proto and constructor to prevent pollution", () => { + expect(parseQuery("__proto__=evil&constructor=bad&valid=ok")).toEqual({ + valid: "ok", + }); + }); +});