Skip to content

Commit

Permalink
feat: Prettify URLs
Browse files Browse the repository at this point in the history
Don't URL-encode safe characters, and fix the array parser logic.

Closes #355.
  • Loading branch information
franky47 committed Oct 22, 2023
1 parent 989f165 commit 383aca7
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 10 deletions.
8 changes: 8 additions & 0 deletions packages/next-usequerystate/src/parsers.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, test } from 'vitest'
import {
parseAsArrayOf,
parseAsFloat,
parseAsHex,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
parseAsTimestamp
} from './parsers'

Expand Down Expand Up @@ -46,4 +48,10 @@ describe('parsers', () => {
ref
)
})
test('parseAsArrayOf', () => {
const parser = parseAsArrayOf(parseAsString)
expect(parser.serialize([])).toBe('')
// It encodes its separator
expect(parser.serialize(['a', ',', 'b'])).toBe('a,%2C,b')
})
})
15 changes: 8 additions & 7 deletions packages/next-usequerystate/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function parseAsArrayOf<ItemType>(
itemParser: Parser<ItemType>,
separator = ','
) {
const encodedSeparator = encodeURIComponent(separator)
// todo: Handle default item values and make return type non-nullable
return createParser({
parse: query => {
Expand All @@ -274,19 +275,19 @@ export function parseAsArrayOf<ItemType>(
}
return query
.split(separator)
.map(item => decodeURIComponent(item))
.map(itemParser.parse)
.map(item =>
itemParser.parse(item.replaceAll(encodedSeparator, separator))
)
.filter(value => value !== null && value !== undefined) as ItemType[]
},
serialize: values =>
values
.map<string>(value => {
if (itemParser.serialize) {
return itemParser.serialize(value)
}
return `${value}`
const str = itemParser.serialize
? itemParser.serialize(value)
: String(value)
return str.replaceAll(separator, encodedSeparator)
})
.map(encodeURIComponent)
.join(separator)
})
}
3 changes: 2 additions & 1 deletion packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Options, Router } from './defs'
import { NOSYNC_MARKER } from './sync'
import { renderQueryString } from './url-encoding'

// 50ms between calls to the history API seems to satisfy Chrome and Firefox.
// Safari remains annoying with at most 100 calls in 30 seconds. #wontfix
Expand Down Expand Up @@ -110,7 +111,7 @@ function flushUpdateQueue(router: Router) {
}
}

const query = search.toString()
const query = renderQueryString(search)
const path = window.location.pathname
const hash = window.location.hash

Expand Down
97 changes: 97 additions & 0 deletions packages/next-usequerystate/src/url-encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, test } from 'vitest'
import { encodeQueryValue, renderQueryString } from './url-encoding'

describe('url-encoding/encodeQueryValue', () => {
test('spaces are encoded as +', () => {
expect(encodeQueryValue(' ')).toBe('+')
})
test('+ are encoded', () => {
expect(encodeQueryValue('+')).toBe(encodeURIComponent('+'))
})
test('Hashes are encoded', () => {
expect(encodeQueryValue('#')).toBe(encodeURIComponent('#'))
})
test('Ampersands are encoded', () => {
expect(encodeQueryValue('&')).toBe(encodeURIComponent('&'))
})
test('Percent signs are encoded', () => {
expect(encodeQueryValue('%')).toBe(encodeURIComponent('%'))
})
test('Alphanumericals are passed through', () => {
const input = 'abcdefghijklmnopqrstuvwxyz0123456789'
expect(encodeQueryValue(input)).toBe(input)
})
test('Other special characters are passed through', () => {
const input = '-._~!$\'()*,;=:@"/?`[]{}\\|<>^'
expect(encodeQueryValue(input)).toBe(input)
})
test('practical use-cases', () => {
const e = encodeQueryValue
expect(e('a b')).toBe('a+b')
expect(e('some#secret')).toBe('some%23secret')
expect(e('2+2=5')).toBe('2%2B2=5')
expect(e('100%')).toBe('100%25')
expect(e('kool&thegang')).toBe('kool%26thegang')
expect(e('a&b=c')).toBe('a%26b=c')
})
})

describe('url-encoding/renderQueryString', () => {
test('empty query', () => {
expect(renderQueryString(new URLSearchParams())).toBe('')
})
test('simple key-value pair', () => {
const search = new URLSearchParams()
search.set('foo', 'bar')
expect(renderQueryString(search)).toBe('foo=bar')
})
test('encoding', () => {
const search = new URLSearchParams()
search.set('test', '-._~!$\'()*,;=:@"/?`[]{}\\|<>^')
expect(renderQueryString(search)).toBe(
'test=-._~!$\'()*,;=:@"/?`[]{}\\|<>^'
)
})
test('decoding', () => {
const search = new URLSearchParams()
const value = '-._~!$\'()*,;=:@"/?`[]{}\\|<>^'
search.set('test', value)
const url = new URL('http://example.com/?' + renderQueryString(search))
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding plus and spaces', () => {
const search = new URLSearchParams()
const value = 'a b+c'
search.set('test', value)
const url = new URL('http://example.com/?' + renderQueryString(search))
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding hashes and fragment', () => {
const search = new URLSearchParams()
const value = 'foo#bar'
search.set('test', value)
const url = new URL(
'http://example.com/?' + renderQueryString(search) + '#egg'
)
expect(url.searchParams.get('test')).toBe(value)
})
test('decoding ampersands', () => {
const search = new URLSearchParams()
const value = 'a&b=c'
search.set('test', value)
const url = new URL(
'http://example.com/?' + renderQueryString(search) + '&egg=spam'
)
expect(url.searchParams.get('test')).toBe(value)
})
test('it renders query string with special characters', () => {
const search = new URLSearchParams()
search.set('name', 'John Doe')
search.set('email', '[email protected]')
search.set('message', 'Hello, world! #greeting')
const query = renderQueryString(search)
expect(query).toBe(
'name=John+Doe&email=foo.bar%[email protected]&message=Hello,+world!+%23greeting'
)
})
})
24 changes: 24 additions & 0 deletions packages/next-usequerystate/src/url-encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function renderQueryString(search: URLSearchParams) {
const query: string[] = []
for (const [key, value] of search.entries()) {
query.push(`${key}=${encodeQueryValue(value)}`)
}
return query.join('&')
}

export function encodeQueryValue(input: string) {
return (
input
// Encode existing % signs first to avoid appearing
// as an incomplete escape sequence:
.replace(/%/g, '%25')
// Note: spaces are encoded as + in RFC 3986,
// so we pre-encode existing + signs to avoid confusion
// before converting spaces to + signs.
.replace(/\+/g, '%2B')
.replace(/ /g, '+')
// Encode other URI-reserved characters
.replace(/#/g, '%23')
.replace(/&/g, '%26')
)
}
4 changes: 2 additions & 2 deletions packages/playground/src/app/demos/compound-parsers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { parseAsArrayOf, parseAsJson, useQueryState } from 'next-usequerystate'

const escaped = '-_.!~*\'()?#/&,"`<>{}[]@$£%+=:;'
const escaped = '-_.!~*\'()?#/&,"`<>{}[]|•@$£%+=:;'

export default function CompoundParsersDemo() {
const [code, setCode] = useQueryState(
Expand All @@ -11,7 +11,7 @@ export default function CompoundParsersDemo() {
)
const [array, setArray] = useQueryState(
'array',
parseAsArrayOf(parseAsJson<any>()).withDefault([])
parseAsArrayOf(parseAsJson<any>(), ';').withDefault([])
)
return (
<>
Expand Down
110 changes: 110 additions & 0 deletions packages/playground/src/app/demos/custom-parser/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client'

import { createParser, useQueryState } from 'next-usequerystate'

type SortingState = Record<string, 'asc' | 'desc'>

const parser = createParser({
parse(value) {
if (value === '') {
return null
}
const keys = value.split('|')
return keys.reduce<SortingState>((acc, key) => {
const [id, desc] = key.split(':')
acc[id] = desc === 'desc' ? 'desc' : 'asc'
return acc
}, {})
},
serialize(value: SortingState) {
return Object.entries(value)
.map(([id, dir]) => `${id}:${dir}`)
.join('|')
}
})

export default function BasicCounterDemoPage() {
const [sort, setSort] = useQueryState('sort', parser.withDefault({}))
return (
<section>
<h1>Custom parser</h1>
<nav style={{ display: 'flex', gap: '4px' }}>
<span>Foo</span>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
foo: 'asc'
}))
}
>
🔼
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
foo: 'desc'
}))
}
>
🔽
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(({ foo: _, ...state }) =>
Object.keys(state).length === 0 ? null : state
)
}
>
Clear
</button>
<span>{sort.foo}</span>
</nav>
<nav style={{ display: 'flex', gap: '4px' }}>
<span>Bar</span>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
bar: 'asc'
}))
}
>
🔼
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(state => ({
...state,
bar: 'desc'
}))
}
>
🔽
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() =>
setSort(({ bar: _, ...state }) =>
Object.keys(state).length === 0 ? null : state
)
}
>
Clear
</button>
<span>{sort.bar}</span>
</nav>
<p>
<a href="https://github.com/47ng/next-usequerystate/blob/next/src/app/demos/custom-parser/page.tsx">
Source on GitHub
</a>
</p>
</section>
)
}
1 change: 1 addition & 0 deletions packages/playground/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const demos = [
'app/server-side-parsing',
'app/hex-colors',
'app/compound-parsers',
'app/custom-parser',
'app/crosslink',
'app/repro-359',
// Pages router demos
Expand Down

0 comments on commit 383aca7

Please sign in to comment.