diff --git a/packages/next-usequerystate/src/parsers.test.ts b/packages/next-usequerystate/src/parsers.test.ts index f04500ad..82ec9029 100644 --- a/packages/next-usequerystate/src/parsers.test.ts +++ b/packages/next-usequerystate/src/parsers.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'vitest' import { + parseAsArrayOf, parseAsFloat, parseAsHex, parseAsInteger, parseAsIsoDateTime, + parseAsString, parseAsTimestamp } from './parsers' @@ -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') + }) }) diff --git a/packages/next-usequerystate/src/parsers.ts b/packages/next-usequerystate/src/parsers.ts index 7b611efa..3242eedf 100644 --- a/packages/next-usequerystate/src/parsers.ts +++ b/packages/next-usequerystate/src/parsers.ts @@ -264,6 +264,7 @@ export function parseAsArrayOf( itemParser: Parser, separator = ',' ) { + const encodedSeparator = encodeURIComponent(separator) // todo: Handle default item values and make return type non-nullable return createParser({ parse: query => { @@ -274,19 +275,19 @@ export function parseAsArrayOf( } 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(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) }) } diff --git a/packages/next-usequerystate/src/update-queue.ts b/packages/next-usequerystate/src/update-queue.ts index 1ad028fd..d0522237 100644 --- a/packages/next-usequerystate/src/update-queue.ts +++ b/packages/next-usequerystate/src/update-queue.ts @@ -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 @@ -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 diff --git a/packages/next-usequerystate/src/url-encoding.test.ts b/packages/next-usequerystate/src/url-encoding.test.ts new file mode 100644 index 00000000..78e984d4 --- /dev/null +++ b/packages/next-usequerystate/src/url-encoding.test.ts @@ -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', 'foo.bar+egg-spam@example.com') + search.set('message', 'Hello, world! #greeting') + const query = renderQueryString(search) + expect(query).toBe( + 'name=John+Doe&email=foo.bar%2Begg-spam@example.com&message=Hello,+world!+%23greeting' + ) + }) +}) diff --git a/packages/next-usequerystate/src/url-encoding.ts b/packages/next-usequerystate/src/url-encoding.ts new file mode 100644 index 00000000..f04d882b --- /dev/null +++ b/packages/next-usequerystate/src/url-encoding.ts @@ -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') + ) +} diff --git a/packages/playground/src/app/demos/compound-parsers/page.tsx b/packages/playground/src/app/demos/compound-parsers/page.tsx index 0451ac35..65067dbc 100644 --- a/packages/playground/src/app/demos/compound-parsers/page.tsx +++ b/packages/playground/src/app/demos/compound-parsers/page.tsx @@ -2,7 +2,7 @@ import { parseAsArrayOf, parseAsJson, useQueryState } from 'next-usequerystate' -const escaped = '-_.!~*\'()?#/&,"`<>{}[]@$£%+=:;' +const escaped = '-_.!~*\'()?#/&,"`<>{}[]|•@$£%+=:;' export default function CompoundParsersDemo() { const [code, setCode] = useQueryState( @@ -11,7 +11,7 @@ export default function CompoundParsersDemo() { ) const [array, setArray] = useQueryState( 'array', - parseAsArrayOf(parseAsJson()).withDefault([]) + parseAsArrayOf(parseAsJson(), ';').withDefault([]) ) return ( <> diff --git a/packages/playground/src/app/demos/custom-parser/page.tsx b/packages/playground/src/app/demos/custom-parser/page.tsx new file mode 100644 index 00000000..ae5d35cc --- /dev/null +++ b/packages/playground/src/app/demos/custom-parser/page.tsx @@ -0,0 +1,110 @@ +'use client' + +import { createParser, useQueryState } from 'next-usequerystate' + +type SortingState = Record + +const parser = createParser({ + parse(value) { + if (value === '') { + return null + } + const keys = value.split('|') + return keys.reduce((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 ( +
+

Custom parser

+ + +

+ + Source on GitHub + +

+
+ ) +} diff --git a/packages/playground/src/app/page.tsx b/packages/playground/src/app/page.tsx index a12e9d71..23810c5e 100644 --- a/packages/playground/src/app/page.tsx +++ b/packages/playground/src/app/page.tsx @@ -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