Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
},
"devDependencies": {
"@types/node": "^18.19.130",
"@types/qs": "^6.14.0",
"es-check": "^9.5.3",
"esbuild": "^0.27.2",
"esbuild-node-externals": "^1.20.1",
Expand Down
157 changes: 157 additions & 0 deletions packages/core/src/queryString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { QueryStringArrayFormatOption } from './types'

/**
* Returns true if the given URL query string contains indexed array parameters.
*/
export function hasIndices(url: URL): boolean {
return /\[\d+\]/.test(decodeURIComponent(url.search))
}

/**
* Parse a query string into a nested object.
*/
export function parse(query: string): Record<string, unknown> {
if (!query || query === '?') {
return {}
}

const result: Record<string, unknown> = {}

query
.replace(/^\?/, '')
.split('&')
.filter(Boolean)
.forEach((segment) => {
const [rawKey, rawValue] = splitPair(segment)

set(result, decode(rawKey), decode(rawValue))
})

return result
}

/**
* Convert an object to a query string.
*/
export function stringify(data: Record<string, unknown>, arrayFormat: QueryStringArrayFormatOption): string {
const pairs: string[] = []

build(data, '', pairs, arrayFormat)

return pairs.length ? '?' + pairs.join('&') : ''
}

/**
* Split a query string pair into key and value.
*/
function splitPair(pair: string): [string, string] {
const index = pair.indexOf('=')

return index === -1 ? [pair, ''] : [pair.substring(0, index), pair.substring(index + 1)]
}

/**
* Decode a query string component.
*/
function decode(value: string): string {
// "hello+world" -> "hello world" (+ is a legacy space encoding from forms)
return decodeURIComponent(value.replace(/\+/g, ' '))
}

/**
* Set an item on an object using bracket notation.
*/
function set(target: Record<string, unknown>, key: string, value: string): void {
// "user[profile][name]" -> ["user", "profile", "name"]
const keys = parseKey(key)

let current = target

while (keys.length > 1) {
const segment = keys.shift()!
const nextIsArrayPush = keys[0] === ''

// If the key doesn't exist at this depth, we will just create an empty
// array or object to hold the next value, allowing us to create the
// structures to hold final values at the correct depth.
// "tags[]" needs an array, "user[profile]" needs an object.
if (typeof current[segment] !== 'object' || current[segment] === null) {
current[segment] = nextIsArrayPush ? [] : {}
}

current = current[segment] as Record<string, unknown>
}

const final = keys.shift()!

// "tags[]=vue&tags[]=react" pushes to array: { tags: ["vue", "react"] }
// "user[name]=John" sets on object: { user: { name: "John" } }
if (final === '' && Array.isArray(current)) {
current.push(value)
} else {
current[final] = value
}
}

/**
* Parse a bracket notation key into segments.
*/
function parseKey(key: string): string[] {
const segments: string[] = []

// "filters[status]" -> base is "filters"
const base = key.split('[')[0]

if (base) {
segments.push(base)
}

// "user[profile][name]" -> ["user", "profile", "name"]
// "tags[]" -> ["tags", ""] (empty string indicates array push)
let match
const pattern = /\[([^\]]*)\]/g

while ((match = pattern.exec(key)) !== null) {
segments.push(match[1])
}

return segments
}

/**
* Recursively build query string pairs from a value.
*/
function build(value: unknown, prefix: string, pairs: string[], arrayFormat: QueryStringArrayFormatOption): void {
// { cleared: undefined } -> key is omitted entirely
if (value === undefined) {
return
}

// { cleared: null } -> "cleared=" (empty value, key preserved)
if (value === null) {
pairs.push(`${prefix}=`)
return
}

// { tags: ["vue", "react"] } -> "tags[]=vue&tags[]=react" (brackets)
// -> "tags[0]=vue&tags[1]=react" (indices)
if (Array.isArray(value)) {
value.forEach((item, index) => {
const key = arrayFormat === 'indices' ? `${prefix}[${index}]` : `${prefix}[]`

build(item, key, pairs, arrayFormat)
})
return
}

// { user: { name: "John" } } -> "user[name]=John"
if (typeof value === 'object') {
Object.keys(value).forEach((key) => {
build((value as Record<string, unknown>)[key], prefix ? `${prefix}[${key}]` : key, pairs, arrayFormat)
})
return
}

// { search: "hello world" } -> "search=hello%20world"
pairs.push(`${prefix}=${encodeURIComponent(String(value))}`)
}
14 changes: 3 additions & 11 deletions packages/core/src/url.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as qs from 'qs'
import { config } from './config'
import { hasFiles } from './files'
import { isFormData, objectToFormData } from './formData'
import * as queryString from './queryString'
import type {
FormDataConvertible,
Method,
Expand Down Expand Up @@ -63,16 +63,8 @@ export function mergeDataIntoQueryString<T extends RequestPayload>(
if (hasDataForQueryString) {
// If the original URL contains indices notation (e.g. [0], [1]), preserve it.
// Indices notation cannot be converted to brackets notation without data loss.
// We decode the URL search first because browsers may return URL-encoded brackets (%5B0%5D).
const hasIndices = /\[\d+\]/.test(decodeURIComponent(url.search))
const parseOptions = { ignoreQueryPrefix: true, allowSparse: true }
url.search = qs.stringify(
{ ...qs.parse(url.search, parseOptions), ...data },
{
encodeValuesOnly: true,
arrayFormat: hasIndices ? 'indices' : qsArrayFormat,
},
)
const arrayFormat = queryString.hasIndices(url) ? 'indices' : qsArrayFormat
url.search = queryString.stringify({ ...queryString.parse(url.search), ...data }, arrayFormat)
}

return [
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions tests/core/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,48 @@ test.describe('url.ts', () => {
expect(href).toBe('https://example.com/search?existing=value&new=param')
expect(data).toEqual({})
})

test('decodes plus signs as spaces in existing query string values', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search?q=hello+world', { filter: 'test' })

expect(href).toBe('/search?q=hello%20world&filter=test')
expect(data).toEqual({})
})

test('properly encodes values containing ampersands', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search', { q: 'foo&bar' })

expect(href).toBe('/search?q=foo%26bar')
expect(data).toEqual({})
})

test('properly encodes values containing equals signs', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search', { equation: '1+1=2' })

expect(href).toBe('/search?equation=1%2B1%3D2')
expect(data).toEqual({})
})

test('properly encodes values containing question marks', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search', { q: 'what?' })

expect(href).toBe('/search?q=what%3F')
expect(data).toEqual({})
})

test('returns URL unchanged when data is empty object', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search?existing=value', {})

expect(href).toBe('/search?existing=value')
expect(data).toEqual({})
})

test('handles values containing hash symbols', () => {
const [href, data] = mergeDataIntoQueryString('get', '/search', { color: '#ff0000' })

expect(href).toBe('/search?color=%23ff0000')
expect(data).toEqual({})
})
})

test.describe('non-GET request', () => {
Expand Down