diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index eb5c324d..ba742244 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -111,6 +111,7 @@ export default defineConfig({ { text: 'RouterReplace', link: '/api/types/RouterReplace' }, { text: 'RouterResolve', link: '/api/types/RouterResolve' }, { text: 'Routes', link: '/api/types/Routes' }, + { text: 'QuerySource', link: '/api/types/QuerySource' }, { text: 'Url', link: '/api/types/Url' }, ], }, diff --git a/docs/api/components/RouterLink.md b/docs/api/components/RouterLink.md index 7db3be94..8735d4e0 100644 --- a/docs/api/components/RouterLink.md +++ b/docs/api/components/RouterLink.md @@ -7,11 +7,13 @@ RouterLink component renders anchor tag (``) for routing both within the SPA | Parameter | Type | | :---- | :---- | | to | [`Url`](/api/types/Url) \| `(resolve: RouterResolve) => Url` | -| query | `Record` \| `undefined` | +| query | `QuerySource` \| `undefined` | | hash | `string` \| `undefined` | | replace | `boolean` \| `undefined` | | prefetch | `boolean` \| `PrefetchConfigOptions` \| `undefined` | +[QuerySource](/api/types/QuerySource) + [RouterResolve](/api/types/RouterResolve) ## Slots diff --git a/docs/api/types/QuerySource.md b/docs/api/types/QuerySource.md new file mode 100644 index 00000000..c998de1b --- /dev/null +++ b/docs/api/types/QuerySource.md @@ -0,0 +1,7 @@ +# QuerySource + +QuerySource is our name for the constructor type passed to the built in [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams). + +```ts +export type QuerySource = ConstructorParameters[0] +``` diff --git a/docs/api/types/RouterPush.md b/docs/api/types/RouterPush.md index 4d6317ac..f2b46e35 100644 --- a/docs/api/types/RouterPush.md +++ b/docs/api/types/RouterPush.md @@ -19,12 +19,14 @@ Push will update the URL for the browser and also add the URL into the history s ```ts { - query?: Record, + query?: QuerySource, replace?: boolean, hash?: string, } ``` +[QuerySource](/api/types/QuerySource) + ## Returns `Promise` diff --git a/docs/api/types/RouterReplace.md b/docs/api/types/RouterReplace.md index 8d2558b0..7cd1ed2e 100644 --- a/docs/api/types/RouterReplace.md +++ b/docs/api/types/RouterReplace.md @@ -19,11 +19,13 @@ Replace has the same effect as [`RouterPush`](/api/types/RouterPush) but without ```ts { - query?: Record, + query?: QuerySource, hash?: string, } ``` +[QuerySource](/api/types/QuerySource) + ## Returns `Promise` diff --git a/docs/api/types/RouterResolve.md b/docs/api/types/RouterResolve.md index 4e324a8e..682cdc67 100644 --- a/docs/api/types/RouterResolve.md +++ b/docs/api/types/RouterResolve.md @@ -33,11 +33,13 @@ If source is `Url`, expected type for params is `never`. Else when source is `TS ```ts { - query?: Record, + query?: QuerySource, hash?: string, } ``` +[QuerySource](/api/types/QuerySource) + ## Returns `Url` diff --git a/src/services/createRouter.spec.ts b/src/services/createRouter.spec.ts index 5ed230e2..e7bd1aac 100644 --- a/src/services/createRouter.spec.ts +++ b/src/services/createRouter.spec.ts @@ -306,7 +306,7 @@ test('query.delete updates the route', async () => { expect(route.query.toString()).toBe('fiz=buz') }) -test.fails('query.values is reactive', async () => { +test('query.values is reactive', async () => { const root = createRoute({ name: 'root', component, @@ -314,20 +314,20 @@ test.fails('query.values is reactive', async () => { }) const { route, start } = createRouter([root], { - initialUrl: '/?foo=bar&fiz=buz', + initialUrl: '/?foo=foo1&bar=bar1', }) await start() const values = computed(() => Array.from(route.query.values())) - expect(values.value).toMatchObject(['bar', 'buz']) + expect(values.value).toMatchObject(['foo1', 'bar1']) - route.query.append('foo', 'bar2') + route.query.append('foo', 'foo2') await flushPromises() - expect(values.value).toMatchObject(['bar', 'buz', 'bar2']) + expect(values.value).toMatchObject(['foo1', 'bar1', 'foo2']) }) test('given an array of Routes, combines into single routes collection', () => { diff --git a/src/services/createRouterResolve.ts b/src/services/createRouterResolve.ts index c0ef3527..35f69a04 100644 --- a/src/services/createRouterResolve.ts +++ b/src/services/createRouterResolve.ts @@ -6,11 +6,12 @@ import { RoutesName } from '@/types/routesMap' import { RouteParamsByKey } from '@/types/routeWithParams' import { isUrl, Url } from '@/types/url' import { AllPropertiesAreOptional } from '@/types/utilities' -import { createUrl } from './urlCreator' -import { parseUrl } from './urlParser' +import { createUrl } from '@/services/urlCreator' +import { parseUrl } from '@/services/urlParser' +import { QuerySource } from '@/types/query' export type RouterResolveOptions = { - query?: Record, + query?: QuerySource, hash?: string, } diff --git a/src/services/createRouterRoute.ts b/src/services/createRouterRoute.ts index 460a755b..5f563bc0 100644 --- a/src/services/createRouterRoute.ts +++ b/src/services/createRouterRoute.ts @@ -46,21 +46,21 @@ export function createRouterRoute(route: TRoute, p const query = new URLSearchParams(route.query.toString()) query.set(...parameters) - update({}, { query: Object.fromEntries(query.entries()) }) + update({}, { query }) } const queryAppend: URLSearchParams['append'] = (...parameters) => { const query = new URLSearchParams(route.query.toString()) query.append(...parameters) - update({}, { query: Object.fromEntries(query.entries()) }) + update({}, { query }) } const queryDelete: URLSearchParams['delete'] = (...parameters) => { const query = new URLSearchParams(route.query.toString()) query.delete(...parameters) - update({}, { query: Object.fromEntries(query.entries()) }) + update({}, { query }) } const { id, matched, matches, name, query, params, state, hash } = toRefs(route) diff --git a/src/services/routeMatchScore.ts b/src/services/routeMatchScore.ts index f7d00f2b..21cd9d5e 100644 --- a/src/services/routeMatchScore.ts +++ b/src/services/routeMatchScore.ts @@ -1,7 +1,7 @@ import { parseUrl } from '@/services/urlParser' import { getParamValueFromUrl } from '@/services/paramsFinder' import { Route } from '@/types/route' -import { routeHashMatches } from './routeMatchRules' +import { routeHashMatches } from '@/services/routeMatchRules' type RouteSortMethod = (aRoute: Route, bRoute: Route) => number diff --git a/src/services/urlAssembly.ts b/src/services/urlAssembly.ts index aa27e9a3..c3314f2d 100644 --- a/src/services/urlAssembly.ts +++ b/src/services/urlAssembly.ts @@ -5,23 +5,23 @@ import { getParamName, isOptionalParamSyntax } from '@/services/routeRegex' import { Host } from '@/types/host' import { paramEnd, paramStart } from '@/types/params' import { Path } from '@/types/path' -import { Query } from '@/types/query' +import { Query, QuerySource } from '@/types/query' import { Route } from '@/types/route' import { Url } from '@/types/url' -import { createUrl } from './urlCreator' -import { parseUrl } from './urlParser' +import { createUrl } from '@/services/urlCreator' +import { parseUrl } from '@/services/urlParser' +import { combineUrlSearchParams } from '@/utilities/urlSearchParams' -export type QueryRecord = Record type AssembleUrlOptions = { params?: Record, - query?: Record, + query?: QuerySource, hash?: string, } export function assembleUrl(route: Route, options: AssembleUrlOptions = {}): Url { const { params: paramValues = {}, query: queryValues } = options - const queryWithParamsSet = assembleQueryParamValues(route.query, paramValues) - const searchParams = new URLSearchParams({ ...queryWithParamsSet, ...queryValues }) + const routeQuery = assembleQueryParamValues(route.query, paramValues) + const searchParams = combineUrlSearchParams(routeQuery, queryValues) const pathname = assemblePathParamValues(route.path, paramValues) const hash = createHash(route.hash.value ?? options.hash).value @@ -57,19 +57,19 @@ function assemblePathParamValues(path: Path, paramValues: Record): QueryRecord { +function assembleQueryParamValues(query: Query, paramValues: Record): URLSearchParams { + const search = new URLSearchParams(query.value) + if (!query.value) { - return {} + return search } - const search = new URLSearchParams(query.value) - - return Array.from(search.entries()).reduce((url, [key, value]) => { + for (const [key, value] of search.entries()) { const paramName = getParamName(value) const isNotParam = !paramName if (isNotParam) { - return { ...url, [key]: value } + continue } const paramValue = setParamValue(paramValues[paramName], query.params[paramName], isOptionalParamSyntax(value)) @@ -77,9 +77,11 @@ function assembleQueryParamValues(query: Query, paramValues: Record[0] + type ExtractQueryParamsFromQueryString< TQuery extends string, TParams extends Record = Record diff --git a/src/types/routerPush.ts b/src/types/routerPush.ts index b239078c..9aa32058 100644 --- a/src/types/routerPush.ts +++ b/src/types/routerPush.ts @@ -4,11 +4,12 @@ import { RouteParamsByKey } from '@/types/routeWithParams' import { RouteStateByName } from '@/types/state' import { Url } from '@/types/url' import { AllPropertiesAreOptional } from '@/types/utilities' +import { QuerySource } from '@/types/query' export type RouterPushOptions< TState = unknown > = { - query?: Record, + query?: QuerySource, hash?: string, replace?: boolean, state?: Partial, diff --git a/src/types/routerReplace.ts b/src/types/routerReplace.ts index ee3648e3..f58bd8c0 100644 --- a/src/types/routerReplace.ts +++ b/src/types/routerReplace.ts @@ -4,11 +4,12 @@ import { RouteParamsByKey } from '@/types/routeWithParams' import { RouteStateByName } from '@/types/state' import { Url } from '@/types/url' import { AllPropertiesAreOptional } from '@/types/utilities' +import { QuerySource } from '@/types/query' export type RouterReplaceOptions< TState = unknown > = { - query?: Record, + query?: QuerySource, hash?: string, state?: Partial, } diff --git a/src/utilities/urlSearchParams.spec.ts b/src/utilities/urlSearchParams.spec.ts new file mode 100644 index 00000000..4c823ebb --- /dev/null +++ b/src/utilities/urlSearchParams.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest' +import { combineUrlSearchParams } from './urlSearchParams' + +test.each([ + 'foo=bar', + { foo: 'bar' }, + [['foo', 'bar']], +])('given different constructor types, normalizes into URLSearchParams', (params) => { + const response = combineUrlSearchParams(params, undefined) + + expect(Array.from(response.entries())).toMatchObject([ + ['foo', 'bar'], + ]) +}) + +test('given duplicate keys, appends new entry', () => { + const aParams = new URLSearchParams({ foo: 'foo' }) + const bParams = new URLSearchParams({ foo: 'bar' }) + + const response = combineUrlSearchParams(aParams, bParams) + + expect(Array.from(response.entries())).toMatchObject([ + ['foo', 'foo'], + ['foo', 'bar'], + ]) +}) diff --git a/src/utilities/urlSearchParams.ts b/src/utilities/urlSearchParams.ts new file mode 100644 index 00000000..307701b8 --- /dev/null +++ b/src/utilities/urlSearchParams.ts @@ -0,0 +1,12 @@ +import { QuerySource } from '@/types/query' + +export function combineUrlSearchParams(aParams: URLSearchParams | QuerySource, bParams: URLSearchParams | QuerySource): URLSearchParams { + const combinedParams = new URLSearchParams(aParams) + const paramsToAdd = new URLSearchParams(bParams) + + for (const [key, value] of paramsToAdd.entries()) { + combinedParams.append(key, value) + } + + return combinedParams +}