+++ /dev/null
-import { describe, expect, it } from 'vitest'
-import { defineComponent } from 'vue'
-import { RouteComponent, RouteMeta, RouteRecordRaw } from '../../../types'
-import { NEW_stringifyURL } from '../../../location'
-import { mockWarn } from '../../../../__tests__/vitest-mock-warn'
-import {
- type MatcherLocationRaw,
- type ResolverLocationResolved,
- NO_MATCH_LOCATION,
-} from '../resolver-abstract'
-import { type NEW_MatcherRecord } from './resolver-dynamic'
-import { type NEW_MatcherRecordRaw } from './resolver-dynamic'
-import { createCompiledMatcher } from './resolver-dynamic'
-import { miss } from '../matchers/errors'
-import {
- MatcherPatternPath,
- MatcherPatternPathStatic,
-} from '../matchers/matcher-pattern'
-import { EXPERIMENTAL_RouterOptions } from '../../router'
-import { stringifyQuery } from '../../../query'
-import type { ResolverLocationAsPathAbsolute } from '../resolver-abstract'
-import type { ResolverLocationAsNamed } from '../resolver-abstract'
-// TODO: should be moved to a different test file
-// used to check backward compatible paths
-import {
- PATH_PARSER_OPTIONS_DEFAULTS,
- PathParams,
- tokensToParser,
-} from '../../../matcher/pathParserRanker'
-import { tokenizePath } from '../../../matcher/pathTokenizer'
-import { mergeOptions } from '../../../utils'
-
-// FIXME: this type was removed, it will be a new one once a dynamic resolver is implemented
-export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw {
- /**
- * Arbitrary data attached to the record.
- */
- meta?: RouteMeta
-
- components?: Record<string, unknown>
- component?: unknown
-
- redirect?: unknown
- score: Array<number[]>
- readonly options: EXPERIMENTAL_RouterOptions
-}
-
-// for raw route record
-const component: RouteComponent = defineComponent({})
-// for normalized route records
-const components = { default: component }
-
-function isMatchable(record: RouteRecordRaw): boolean {
- return !!(
- record.name ||
- (record.components && Object.keys(record.components).length) ||
- record.redirect
- )
-}
-
-export function joinPaths(a: string | undefined, b: string) {
- if (a?.endsWith('/')) {
- return a + b
- }
- return a + '/' + b
-}
-
-function compileRouteRecord(
- record: RouteRecordRaw,
- parentRecord?: RouteRecordRaw
-): NEW_MatcherRecordRaw {
- // we adapt the path to ensure they are absolute
- // TODO: aliases? they could be handled directly in the path matcher
- if (!parentRecord && !record.path.startsWith('/')) {
- throw new Error(`Record without parent must have an absolute path`)
- }
- const path = record.path.startsWith('/')
- ? record.path
- : joinPaths(parentRecord?.path, record.path)
- record.path = path
- const parser = tokensToParser(
- tokenizePath(record.path),
- mergeOptions(PATH_PARSER_OPTIONS_DEFAULTS, record)
- )
-
- return {
- group: !isMatchable(record),
- name: record.name,
- score: parser.score,
-
- path: {
- match(value) {
- const params = parser.parse(value)
- if (params) {
- return params
- }
- throw miss()
- },
- build(params) {
- // TODO: normalize params?
- return parser.stringify(params)
- },
- } satisfies MatcherPatternPath<PathParams>,
-
- children: record.children?.map(childRecord =>
- compileRouteRecord(childRecord, record)
- ),
- }
-}
-
-describe('RouterMatcher.resolve', () => {
- mockWarn()
- type Matcher = ReturnType<typeof createCompiledMatcher>
- type MatcherResolvedLocation = ReturnType<Matcher['resolve']>
-
- const START_LOCATION: MatcherResolvedLocation = {
- name: Symbol('START'),
- params: {},
- path: '/',
- fullPath: '/',
- query: {},
- hash: '',
- matched: [],
- // meta: {},
- }
-
- function isMatcherLocationResolved(
- location: unknown
- ): location is ResolverLocationResolved<NEW_MatcherRecord> {
- return !!(
- location &&
- typeof location === 'object' &&
- 'matched' in location &&
- 'fullPath' in location &&
- Array.isArray(location.matched)
- )
- }
-
- function isExperimentalRouteRecordRaw(
- record: Record<any, any>
- ): record is EXPERIMENTAL_RouteRecordRaw {
- return typeof record.path !== 'string'
- }
-
- // TODO: rework with object param for clarity
-
- function assertRecordMatch(
- record:
- | EXPERIMENTAL_RouteRecordRaw
- | EXPERIMENTAL_RouteRecordRaw[]
- | RouteRecordRaw
- | RouteRecordRaw[],
- toLocation: Exclude<MatcherLocationRaw, string> | `/${string}`,
- expectedLocation: Partial<MatcherResolvedLocation>,
- fromLocation:
- | ResolverLocationResolved<NEW_MatcherRecord>
- // absolute locations only that can be resolved for convenience
- | `/${string}`
- | ResolverLocationAsNamed
- | ResolverLocationAsPathAbsolute = START_LOCATION
- ) {
- const records = (Array.isArray(record) ? record : [record]).map(
- (record): NEW_MatcherRecordRaw =>
- isExperimentalRouteRecordRaw(record)
- ? { components, ...record }
- : compileRouteRecord(record)
- )
- const matcher = createCompiledMatcher<NEW_MatcherRecord>()
- for (const record of records) {
- matcher.addMatcher(record)
- }
-
- const path =
- typeof toLocation === 'string' ? toLocation : toLocation.path || '/'
-
- const resolved: Omit<MatcherResolvedLocation, 'matched'> = {
- // FIXME: to add later
- // meta: records[0].meta || {},
- path,
- query: {},
- hash: '',
- // by default we have a symbol on every route
- name: expect.any(Symbol) as symbol,
- // must non enumerable
- // matched: [],
- params: (typeof toLocation === 'object' && toLocation.params) || {},
- fullPath: NEW_stringifyURL(
- stringifyQuery,
- expectedLocation.path || path || '/',
- expectedLocation.query,
- expectedLocation.hash
- ),
- ...expectedLocation,
- }
-
- Object.defineProperty(resolved, 'matched', {
- writable: true,
- configurable: true,
- enumerable: false,
- // FIXME: build it
- value: [],
- })
-
- const resolvedFrom = isMatcherLocationResolved(fromLocation)
- ? fromLocation
- : matcher.resolve(
- // FIXME: is this a ts bug?
- // @ts-expect-error
- fromLocation
- )
-
- const result = matcher.resolve(
- // FIXME: should work now
- // @ts-expect-error
- toLocation,
- resolvedFrom === START_LOCATION ? undefined : resolvedFrom
- )
-
- if (
- expectedLocation.name === undefined ||
- expectedLocation.name !== NO_MATCH_LOCATION.name
- ) {
- expect(result.name).not.toBe(NO_MATCH_LOCATION.name)
- }
-
- expect(result).toMatchObject(resolved)
- }
-
- describe('LocationAsPath', () => {
- it('resolves a normal path', () => {
- assertRecordMatch({ path: '/', name: 'Home', components }, '/', {
- name: 'Home',
- path: '/',
- params: {},
- })
- })
-
- it('resolves a normal path without name', () => {
- assertRecordMatch({ path: '/', components }, '/', {
- path: '/',
- params: {},
- })
- assertRecordMatch(
- { path: '/', components },
- { path: '/' },
- { path: '/', params: {} }
- )
- })
-
- it('resolves a path with params', () => {
- assertRecordMatch(
- { path: '/users/:id', name: 'User', components },
- { path: '/users/posva' },
- { name: 'User', params: { id: 'posva' } }
- )
- })
-
- it('resolves an array of params for a repeatable params', () => {
- assertRecordMatch(
- { path: '/a/:p+', name: 'a', components },
- { name: 'a', params: { p: ['b', 'c', 'd'] } },
- { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } }
- )
- })
-
- it('resolves single params for a repeatable params', () => {
- assertRecordMatch(
- { path: '/a/:p+', name: 'a', components },
- { name: 'a', params: { p: 'b' } },
- { name: 'a', path: '/a/b', params: { p: 'b' } }
- )
- })
-
- it('keeps repeated params as a single one when provided through path', () => {
- assertRecordMatch(
- { path: '/a/:p+', name: 'a', components },
- { path: '/a/b/c' },
- { name: 'a', params: { p: ['b', 'c'] } }
- )
- })
-
- it('resolves a path with multiple params', () => {
- assertRecordMatch(
- { path: '/users/:id/:other', name: 'User', components },
- { path: '/users/posva/hey' },
- { name: 'User', params: { id: 'posva', other: 'hey' } }
- )
- })
-
- it('resolves a path with multiple params but no name', () => {
- assertRecordMatch(
- { path: '/users/:id/:other', components },
- { path: '/users/posva/hey' },
- { name: expect.any(Symbol), params: { id: 'posva', other: 'hey' } }
- )
- })
-
- it('returns an empty match when the path does not exist', () => {
- assertRecordMatch(
- { path: '/', components },
- { path: '/foo' },
- NO_MATCH_LOCATION
- )
- })
-
- it('allows an optional trailing slash', () => {
- assertRecordMatch(
- { path: '/home/', name: 'Home', components },
- { path: '/home/' },
- { name: 'Home', path: '/home/' }
- )
- })
-
- it('allows an optional trailing slash with optional param', () => {
- assertRecordMatch(
- { path: '/:a', components, name: 'a' },
- { path: '/a/' },
- { path: '/a/', params: { a: 'a' }, name: 'a' }
- )
- assertRecordMatch(
- { path: '/a/:a', components, name: 'a' },
- { path: '/a/a/' },
- { path: '/a/a/', params: { a: 'a' }, name: 'a' }
- )
- })
-
- it('allows an optional trailing slash with missing optional param', () => {
- assertRecordMatch(
- { path: '/:a?', components, name: 'a' },
- { path: '/' },
- { path: '/', params: { a: '' }, name: 'a' }
- )
- assertRecordMatch(
- { path: '/a/:a?', components, name: 'a' },
- { path: '/a/' },
- { path: '/a/', params: { a: '' }, name: 'a' }
- )
- })
-
- it('keeps required trailing slash (strict: true)', () => {
- const record = {
- path: '/home/',
- name: 'Home',
- components,
- strict: true,
- }
- assertRecordMatch(record, { path: '/home' }, NO_MATCH_LOCATION)
- assertRecordMatch(
- record,
- { path: '/home/' },
- { name: 'Home', path: '/home/' }
- )
- })
-
- it('rejects a trailing slash when strict', () => {
- const record = {
- path: '/home',
- name: 'Home',
- components,
- strict: true,
- }
- assertRecordMatch(
- record,
- { path: '/home' },
- { name: 'Home', path: '/home' }
- )
- assertRecordMatch(record, { path: '/home/' }, NO_MATCH_LOCATION)
- })
- })
-
- describe('LocationAsName', () => {
- it('matches a name', () => {
- assertRecordMatch(
- { path: '/home', name: 'Home', components },
- // TODO: allow a name only without the params?
- { name: 'Home', params: {} },
- { name: 'Home', path: '/home' }
- )
- })
-
- it('matches a name and fill params', () => {
- assertRecordMatch(
- { path: '/users/:id/m/:role', name: 'UserEdit', components },
- { name: 'UserEdit', params: { id: 'posva', role: 'admin' } },
- {
- name: 'UserEdit',
- path: '/users/posva/m/admin',
- params: { id: 'posva', role: 'admin' },
- }
- )
- })
-
- it('throws if the named route does not exists', () => {
- const matcher = createCompiledMatcher([])
- expect(() => matcher.resolve({ name: 'Home', params: {} })).toThrowError(
- 'Matcher "Home" not found'
- )
- })
-
- it('merges params', () => {
- assertRecordMatch(
- { path: '/:a/:b', name: 'p', components },
- { params: { b: 'b' } },
- { name: 'p', path: '/A/b', params: { a: 'A', b: 'b' } },
- '/A/B'
- )
- })
-
- // TODO: this test doesn't seem useful, it's the same as the test above
- // maybe remove it?
- it('only keep existing params', () => {
- assertRecordMatch(
- { path: '/:a/:b', name: 'p', components },
- { name: 'p', params: { b: 'b' } },
- { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } },
- '/a/c'
- )
- })
-
- // TODO: implement parent children
- it.todo('keep optional params from parent record', () => {
- const Child_A = { path: 'a', name: 'child_a', components }
- const Child_B = { path: 'b', name: 'child_b', components }
- const Parent = {
- path: '/:optional?/parent',
- name: 'parent',
- components,
- children: [Child_A, Child_B],
- }
- assertRecordMatch(
- Parent,
- {},
- {
- name: 'child_b',
- path: '/foo/parent/b',
- params: { optional: 'foo' },
- matched: [
- Parent as any,
- {
- ...Child_B,
- path: `${Parent.path}/${Child_B.path}`,
- },
- ],
- },
- {
- params: { optional: 'foo' },
- // matched: [],
- name: 'child_a',
- }
- )
- })
- // TODO: check if needed by the active matching, if not just test that the param is dropped
-
- it.todo('discards non existent params', () => {
- assertRecordMatch(
- { path: '/', name: 'home', components },
- { name: 'home', params: { a: 'a', b: 'b' } },
- { name: 'home', path: '/', params: {} }
- )
- expect('invalid param(s) "a", "b" ').toHaveBeenWarned()
- assertRecordMatch(
- { path: '/:b', name: 'a', components },
- { name: 'a', params: { a: 'a', b: 'b' } },
- { name: 'a', path: '/b', params: { b: 'b' } }
- )
- expect('invalid param(s) "a"').toHaveBeenWarned()
- })
-
- it('drops optional params in absolute location', () => {
- assertRecordMatch(
- { path: '/:a/:b?', name: 'p', components },
- { name: 'p', params: { a: 'b' } },
- { name: 'p', path: '/b', params: { a: 'b' } }
- )
- })
-
- it('keeps optional params passed as empty strings', () => {
- assertRecordMatch(
- { path: '/:a/:b?', name: 'p', components },
- { name: 'p', params: { a: 'b', b: '' } },
- { name: 'p', path: '/b', params: { a: 'b', b: '' } }
- )
- })
-
- it('resolves root path with optional params', () => {
- assertRecordMatch(
- { path: '/:tab?', name: 'h', components },
- { name: 'h', params: {} },
- { name: 'h', path: '/', params: {} }
- )
- assertRecordMatch(
- { path: '/:tab?/:other?', name: 'h', components },
- { name: 'h', params: {} },
- { name: 'h', path: '/', params: {} }
- )
- })
- })
-
- describe('LocationAsRelative', () => {
- // TODO: not sure where this warning should appear now
- it.todo('warns if a path isn not absolute', () => {
- const matcher = createCompiledMatcher([
- { path: new MatcherPatternPathStatic('/'), score: [[80]] },
- ])
- matcher.resolve({ path: 'two' }, matcher.resolve({ path: '/' }))
- expect('received "two"').toHaveBeenWarned()
- })
-
- it('matches with nothing', () => {
- const record = { path: '/home', name: 'Home', components }
- assertRecordMatch(
- record,
- {},
- { name: 'Home', path: '/home' },
- {
- name: 'Home',
- params: {},
- }
- )
- })
-
- it('replace params even with no name', () => {
- const record = { path: '/users/:id/m/:role', components }
- assertRecordMatch(
- record,
- { params: { id: 'posva', role: 'admin' } },
- { path: '/users/posva/m/admin' },
- {
- path: '/users/ed/m/user',
- // params: { id: 'ed', role: 'user' },
- // matched: [record] as any,
- }
- )
- })
-
- it('replace params', () => {
- const record = {
- path: '/users/:id/m/:role',
- name: 'UserEdit',
- components,
- }
- assertRecordMatch(
- record,
- { params: { id: 'posva', role: 'admin' } },
- { name: 'UserEdit', path: '/users/posva/m/admin' },
- {
- // path: '/users/ed/m/user',
- name: 'UserEdit',
- params: { id: 'ed', role: 'user' },
- // matched: [],
- }
- )
- })
-
- it('keep params if not provided', () => {
- const record = {
- path: '/users/:id/m/:role',
- name: 'UserEdit',
- components,
- }
- assertRecordMatch(
- record,
- {},
- {
- name: 'UserEdit',
- path: '/users/ed/m/user',
- params: { id: 'ed', role: 'user' },
- },
- {
- // path: '/users/ed/m/user',
- name: 'UserEdit',
- params: { id: 'ed', role: 'user' },
- matched: [record] as any,
- }
- )
- })
-
- it('keep params if not provided even with no name', () => {
- const record = { path: '/users/:id/m/:role', components }
- assertRecordMatch(
- record,
- {},
- {
- path: '/users/ed/m/user',
- params: { id: 'ed', role: 'user' },
- },
- {
- path: '/users/ed/m/user',
- // name: undefined,
- // params: { id: 'ed', role: 'user' },
- // matched: [record] as any,
- }
- )
- })
-
- it('merges params', () => {
- assertRecordMatch(
- { path: '/:a/:b?', name: 'p', components },
- { params: { b: 'b' } },
- { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } },
- {
- name: 'p',
- params: { a: 'a' },
- // path: '/a',
- // matched: [],
- }
- )
- })
-
- it('keep optional params', () => {
- assertRecordMatch(
- { path: '/:a/:b?', name: 'p', components },
- {},
- { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } },
- {
- name: 'p',
- params: { a: 'a', b: 'b' },
- // path: '/a/b',
- matched: [],
- }
- )
- })
-
- it('merges optional params', () => {
- assertRecordMatch(
- { path: '/:a/:b?', name: 'p', components },
- { params: { a: 'c' } },
- { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } },
- {
- name: 'p',
- params: { a: 'a', b: 'b' },
- // path: '/a/b',
- matched: [],
- }
- )
- })
-
- it('throws if the current named route does not exists', () => {
- const matcher = createCompiledMatcher([])
- expect(() =>
- matcher.resolve(
- {},
- {
- name: 'ko',
- params: {},
- fullPath: '/',
- hash: '',
- matched: [],
- path: '/',
- query: {},
- }
- )
- ).toThrowError('Matcher "ko" not found')
- })
-
- it('avoids records with children without a component nor name', () => {
- assertRecordMatch(
- {
- path: '/articles',
- children: [{ path: ':id', components }],
- },
- { path: '/articles' },
- NO_MATCH_LOCATION
- )
- })
-
- it('avoids deeply nested records with children without a component nor name', () => {
- assertRecordMatch(
- {
- path: '/app',
- components,
- children: [
- {
- path: '/articles',
- children: [{ path: ':id', components }],
- },
- ],
- },
- { path: '/articles' },
- NO_MATCH_LOCATION
- )
- })
-
- it('can reach a named route with children and no component if named', () => {
- assertRecordMatch(
- {
- path: '/articles',
- name: 'ArticlesParent',
- children: [{ path: ':id', components }],
- },
- { name: 'ArticlesParent', params: {} },
- { name: 'ArticlesParent', path: '/articles' }
- )
- })
- })
-
- describe('children', () => {
- const ChildA: RouteRecordRaw = { path: 'a', name: 'child-a', components }
- const ChildB: RouteRecordRaw = { path: 'b', name: 'child-b', components }
- const ChildC: RouteRecordRaw = { path: 'c', name: 'child-c', components }
- const ChildD: RouteRecordRaw = {
- path: '/absolute',
- name: 'absolute',
- components,
- }
- const ChildWithParam: RouteRecordRaw = {
- path: ':p',
- name: 'child-params',
- components,
- }
- const NestedChildWithParam: RouteRecordRaw = {
- ...ChildWithParam,
- name: 'nested-child-params',
- }
- const NestedChildA: RouteRecordRaw = { ...ChildA, name: 'nested-child-a' }
- const NestedChildB: RouteRecordRaw = { ...ChildB, name: 'nested-child-b' }
- const NestedChildC: RouteRecordRaw = { ...ChildC, name: 'nested-child-c' }
- const Nested: RouteRecordRaw = {
- path: 'nested',
- name: 'nested',
- components,
- children: [NestedChildA, NestedChildB, NestedChildC],
- }
- const NestedWithParam: RouteRecordRaw = {
- path: 'nested/:n',
- name: 'nested',
- components,
- children: [NestedChildWithParam],
- }
-
- it('resolves children', () => {
- const Foo: RouteRecordRaw = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [ChildA, ChildB, ChildC],
- }
- assertRecordMatch(
- Foo,
- { path: '/foo/b' },
- {
- name: 'child-b',
- path: '/foo/b',
- params: {},
- // TODO:
- // matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }],
- }
- )
- })
-
- it('resolves children with empty paths', () => {
- const Nested: RouteRecordRaw = { path: '', name: 'nested', components }
- const Foo: RouteRecordRaw = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Foo,
- { path: '/foo' },
- {
- name: 'nested',
- path: '/foo',
- params: {},
- matched: [Foo as any, { ...Nested, path: `${Foo.path}` }],
- }
- )
- })
-
- it('resolves nested children with empty paths', () => {
- const NestedNested = { path: '', name: 'nested', components }
- const Nested = {
- path: '',
- name: 'nested-nested',
- components,
- children: [NestedNested],
- }
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Foo,
- { path: '/foo' },
- {
- name: 'nested',
- path: '/foo',
- params: {},
- matched: [
- Foo as any,
- { ...Nested, path: `${Foo.path}` },
- { ...NestedNested, path: `${Foo.path}` },
- ],
- }
- )
- })
-
- it('resolves nested children', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Foo,
- { path: '/foo/nested/a' },
- {
- name: 'nested-child-a',
- path: '/foo/nested/a',
- params: {},
- matched: [
- Foo as any,
- { ...Nested, path: `${Foo.path}/${Nested.path}` },
- {
- ...NestedChildA,
- path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`,
- },
- ],
- }
- )
- })
-
- it('resolves nested children with named location', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Foo,
- { name: 'nested-child-a', params: {} },
- {
- name: 'nested-child-a',
- path: '/foo/nested/a',
- params: {},
- // TODO:
- // matched: [
- // Foo as any,
- // { ...Nested, path: `${Foo.path}/${Nested.path}` },
- // {
- // ...NestedChildA,
- // path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`,
- // },
- // ],
- }
- )
- })
-
- it('resolves nested children with relative location', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Foo,
- {},
- {
- name: 'nested-child-a',
- path: '/foo/nested/a',
- params: {},
- matched: [
- Foo as any,
- { ...Nested, path: `${Foo.path}/${Nested.path}` },
- {
- ...NestedChildA,
- path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`,
- },
- ],
- },
- {
- name: 'nested-child-a',
- params: {},
- }
- )
- })
-
- it('resolves nested children with params', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [NestedWithParam],
- }
- assertRecordMatch(
- Foo,
- { path: '/foo/nested/a/b' },
- {
- name: 'nested-child-params',
- path: '/foo/nested/a/b',
- params: { p: 'b', n: 'a' },
- matched: [
- Foo as any,
- {
- ...NestedWithParam,
- path: `${Foo.path}/${NestedWithParam.path}`,
- },
- {
- ...NestedChildWithParam,
- path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`,
- },
- ],
- }
- )
- })
-
- it('resolves nested children with params with named location', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [NestedWithParam],
- }
- assertRecordMatch(
- Foo,
- { name: 'nested-child-params', params: { p: 'a', n: 'b' } },
- {
- name: 'nested-child-params',
- path: '/foo/nested/b/a',
- params: { p: 'a', n: 'b' },
- matched: [
- Foo as any,
- {
- ...NestedWithParam,
- path: `${Foo.path}/${NestedWithParam.path}`,
- },
- {
- ...NestedChildWithParam,
- path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`,
- },
- ],
- }
- )
- })
-
- it('resolves absolute path children', () => {
- const Foo = {
- path: '/foo',
- name: 'Foo',
- components,
- children: [ChildA, ChildD],
- }
- assertRecordMatch(
- Foo,
- { path: '/absolute' },
- {
- name: 'absolute',
- path: '/absolute',
- params: {},
- // TODO:
- // matched: [Foo, ChildD],
- }
- )
- })
-
- it('resolves children with root as the parent', () => {
- const Nested = { path: 'nested', name: 'nested', components }
- const Parent = {
- path: '/',
- name: 'parent',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Parent,
- { path: '/nested' },
- {
- name: 'nested',
- path: '/nested',
- params: {},
- matched: [Parent as any, { ...Nested, path: `/nested` }],
- }
- )
- })
-
- it('resolves children with parent with trailing slash', () => {
- const Nested = { path: 'nested', name: 'nested', components }
- const Parent = {
- path: '/parent/',
- name: 'parent',
- components,
- children: [Nested],
- }
- assertRecordMatch(
- Parent,
- { path: '/parent/nested' },
- {
- name: 'nested',
- path: '/parent/nested',
- params: {},
- matched: [Parent as any, { ...Nested, path: `/parent/nested` }],
- }
- )
- })
- })
-})
+++ /dev/null
-import { describe, expectTypeOf, it } from 'vitest'
-import { ResolverLocationResolved } from '../resolver-abstract'
-import { NEW_MatcherRecordRaw } from './resolver-dynamic'
-import { NEW_RouterResolver } from './resolver-dynamic'
-import { EXPERIMENTAL_RouteRecordNormalized } from '../../router'
-
-describe('Matcher', () => {
- type TMatcherRecordRaw = NEW_MatcherRecordRaw
- type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized
-
- const matcher: NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> =
- {} as any
-
- describe('matcher.resolve()', () => {
- it('resolves absolute string locations', () => {
- expectTypeOf(matcher.resolve({ path: '/foo' })).toEqualTypeOf<
- ResolverLocationResolved<TMatcherRecord>
- >()
- expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf<
- ResolverLocationResolved<TMatcherRecord>
- >()
- })
-
- it('fails on non absolute location without a currentLocation', () => {
- // @ts-expect-error: needs currentLocation
- matcher.resolve('foo')
- // @ts-expect-error: needs currentLocation
- matcher.resolve({ path: 'foo' })
- })
-
- it('resolves relative locations', () => {
- expectTypeOf(
- matcher.resolve(
- { path: 'foo' },
- {} as ResolverLocationResolved<TMatcherRecord>
- )
- ).toEqualTypeOf<ResolverLocationResolved<TMatcherRecord>>()
- expectTypeOf(
- matcher.resolve('foo', {} as ResolverLocationResolved<TMatcherRecord>)
- ).toEqualTypeOf<ResolverLocationResolved<TMatcherRecord>>()
- })
-
- it('resolved named locations', () => {
- expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf<
- ResolverLocationResolved<TMatcherRecord>
- >()
- })
-
- it('fails on object relative location without a currentLocation', () => {
- // @ts-expect-error: needs currentLocation
- matcher.resolve({ params: { id: '1' } })
- // @ts-expect-error: needs currentLocation
- matcher.resolve({ query: { id: '1' } })
- })
-
- it('resolves object relative locations with a currentLocation', () => {
- expectTypeOf(
- matcher.resolve(
- { params: { id: 1 } },
- {} as ResolverLocationResolved<TMatcherRecord>
- )
- ).toEqualTypeOf<ResolverLocationResolved<TMatcherRecord>>()
- })
- })
-
- it('does not allow a name + path', () => {
- matcher.resolve({
- // ...({} as NEW_LocationResolved<TMatcherRecord>),
- name: 'foo',
- params: {},
- // @ts-expect-error: name + path
- path: '/e',
- })
- matcher.resolve(
- // @ts-expect-error: name + currentLocation
- { name: 'a', params: {} },
- //
- {} as ResolverLocationResolved<TMatcherRecord>
- )
- })
-})
+++ /dev/null
-import {
- NEW_stringifyURL,
- LocationNormalized,
- parseURL,
- resolveRelativePath,
-} from '../../../location'
-import { normalizeQuery, stringifyQuery, parseQuery } from '../../../query'
-import type { MatcherParamsFormatted } from '../matchers/matcher-pattern'
-import type { ResolverLocationAsRelative } from '../resolver-abstract'
-import type { ResolverLocationAsPathAbsolute } from '../resolver-abstract'
-import type { ResolverLocationAsPathRelative } from '../resolver-abstract'
-import type { ResolverLocationAsNamed } from '../resolver-abstract'
-import {
- EXPERIMENTAL_Resolver_Base,
- NO_MATCH_LOCATION,
- RecordName,
- ResolverLocationResolved,
-} from '../resolver-abstract'
-import { MatcherQueryParams } from '../matchers/matcher-pattern'
-import { comparePathParserScore } from '../../../matcher/pathParserRanker'
-import { warn } from '../../../warning'
-import type {
- MatcherPatternPath,
- MatcherPatternQuery,
- MatcherPatternHash,
-} from '../matchers/matcher-pattern'
-
-/**
- * Manage and resolve routes. Also handles the encoding, decoding, parsing and
- * serialization of params, query, and hash.
- *
- * - `TMatcherRecordRaw` represents the raw record type passed to {@link addMatcher}.
- * - `TMatcherRecord` represents the normalized record type returned by {@link getRecords}.
- */
-
-export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord>
- extends EXPERIMENTAL_Resolver_Base<TMatcherRecord> {
- /**
- * Add a matcher record. Previously named `addRoute()`.
- * @param matcher - The matcher record to add.
- * @param parent - The parent matcher record if this is a child.
- */
- addMatcher(
- matcher: TMatcherRecordRaw,
- parent?: TMatcherRecord
- ): TMatcherRecord
-
- /**
- * Remove a matcher by its name. Previously named `removeRoute()`.
- * @param matcher - The matcher (returned by {@link addMatcher}) to remove.
- */
- removeMatcher(matcher: TMatcherRecord): void
-
- /**
- * Remove all matcher records. Previously named `clearRoutes()`.
- */
- clearMatchers(): void
-}
-export function createCompiledMatcher<
- TMatcherRecord extends NEW_MatcherDynamicRecord,
->(
- records: NEW_MatcherRecordRaw[] = []
-): NEW_RouterResolver<NEW_MatcherRecordRaw, TMatcherRecord> {
- // TODO: we also need an array that has the correct order
- const matcherMap = new Map<RecordName, TMatcherRecord>()
- const matchers: TMatcherRecord[] = []
-
- // TODO: allow custom encode/decode functions
- // const encodeParams = applyToParams.bind(null, encodeParam)
- // const decodeParams = transformObject.bind(null, String, decode)
- // const encodeQuery = transformObject.bind(
- // null,
- // _encodeQueryKey,
- // encodeQueryValue
- // )
- // const decodeQuery = transformObject.bind(null, decode, decode)
- // NOTE: because of the overloads, we need to manually type the arguments
- type MatcherResolveArgs =
- | [absoluteLocation: `/${string}`, currentLocation?: undefined]
- | [
- relativeLocation: string,
- currentLocation: ResolverLocationResolved<TMatcherRecord>,
- ]
- | [
- absoluteLocation: ResolverLocationAsPathAbsolute,
- // Same as above
- // currentLocation?: NEW_LocationResolved<TMatcherRecord> | undefined
- currentLocation?: undefined,
- ]
- | [
- relativeLocation: ResolverLocationAsPathRelative,
- currentLocation: ResolverLocationResolved<TMatcherRecord>,
- ]
- | [
- location: ResolverLocationAsNamed,
- // Same as above
- // currentLocation?: NEW_LocationResolved<TMatcherRecord> | undefined
- currentLocation?: undefined,
- ]
- | [
- relativeLocation: ResolverLocationAsRelative,
- currentLocation: ResolverLocationResolved<TMatcherRecord>,
- ]
-
- function resolve(
- ...args: MatcherResolveArgs
- ): ResolverLocationResolved<TMatcherRecord> {
- const [to, currentLocation] = args
-
- if (typeof to === 'object' && (to.name || to.path == null)) {
- // relative location or by name
- if (__DEV__ && to.name == null && currentLocation == null) {
- warn(
- `Cannot resolve an unnamed relative location without a current location. This will throw in production.`,
- to
- )
- // NOTE: normally there is no query, hash or path but this helps debug
- // what kind of object location was passed
- // @ts-expect-error: to is never
- const query = normalizeQuery(to.query)
- // @ts-expect-error: to is never
- const hash = to.hash ?? ''
- // @ts-expect-error: to is never
- const path = to.path ?? '/'
- return {
- ...NO_MATCH_LOCATION,
- fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash),
- path,
- query,
- hash,
- }
- }
-
- // either one of them must be defined and is catched by the dev only warn above
- const name = to.name ?? currentLocation?.name
- // FIXME: remove once name cannot be null
- const matcher = name != null && matcherMap.get(name)
- if (!matcher) {
- throw new Error(`Matcher "${String(name)}" not found`)
- }
-
- // unencoded params in a formatted form that the user came up with
- const params: MatcherParamsFormatted = {
- ...currentLocation?.params,
- ...to.params,
- }
- const path = matcher.path.build(params)
- const hash = matcher.hash?.build(params) ?? ''
- const matched = buildMatched(matcher)
- const query = Object.assign(
- {
- ...currentLocation?.query,
- ...normalizeQuery(to.query),
- },
- ...matched.map(matcher => matcher.query?.build(params))
- )
-
- return {
- name,
- fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash),
- path,
- query,
- hash,
- params,
- matched,
- }
- // string location, e.g. '/foo', '../bar', 'baz', '?page=1'
- } else {
- // parseURL handles relative paths
- let url: LocationNormalized
- if (typeof to === 'string') {
- url = parseURL(parseQuery, to, currentLocation?.path)
- } else {
- const query = normalizeQuery(to.query)
- url = {
- fullPath: NEW_stringifyURL(stringifyQuery, to.path, query, to.hash),
- path: resolveRelativePath(to.path, currentLocation?.path || '/'),
- query,
- hash: to.hash || '',
- }
- }
-
- let matcher: TMatcherRecord | undefined
- let matched:
- | ResolverLocationResolved<TMatcherRecord>['matched']
- | undefined
- let parsedParams: MatcherParamsFormatted | null | undefined
-
- for (matcher of matchers) {
- // match the path because the path matcher only needs to be matched here
- // match the hash because only the deepest child matters
- // End up by building up the matched array, (reversed so it goes from
- // root to child) and then match and merge all queries
- try {
- const pathParams = matcher.path.match(url.path)
- const hashParams = matcher.hash?.match(url.hash)
- matched = buildMatched(matcher)
- const queryParams: MatcherQueryParams = Object.assign(
- {},
- ...matched.map(matcher => matcher.query?.match(url.query))
- )
- // TODO: test performance
- // for (const matcher of matched) {
- // Object.assign(queryParams, matcher.query?.match(url.query))
- // }
- parsedParams = { ...pathParams, ...queryParams, ...hashParams }
- // we found our match!
- break
- } catch (e) {
- // for debugging tests
- // console.log('❌ ERROR matching', e)
- }
- }
-
- // No match location
- if (!parsedParams || !matched) {
- return {
- ...url,
- ...NO_MATCH_LOCATION,
- // already decoded
- // query: url.query,
- // hash: url.hash,
- }
- }
-
- return {
- ...url,
- // matcher exists if matched exists
- name: matcher!.name,
- params: parsedParams,
- matched,
- }
- // TODO: handle object location { path, query, hash }
- }
- }
-
- function addMatcher(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) {
- const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol())
- // FIXME: proper normalization of the record
- // @ts-expect-error: we are not properly normalizing the record yet
- const normalizedRecord: TMatcherRecord = {
- ...record,
- name,
- parent,
- children: [],
- }
-
- // insert the matcher if it's matchable
- if (!normalizedRecord.group) {
- const index = findInsertionIndex(normalizedRecord, matchers)
- matchers.splice(index, 0, normalizedRecord)
- // only add the original record to the name map
- if (normalizedRecord.name && !isAliasRecord(normalizedRecord))
- matcherMap.set(normalizedRecord.name, normalizedRecord)
- // matchers.set(name, normalizedRecord)
- }
-
- record.children?.forEach(childRecord =>
- normalizedRecord.children.push(addMatcher(childRecord, normalizedRecord))
- )
-
- return normalizedRecord
- }
-
- for (const record of records) {
- addMatcher(record)
- }
-
- function removeMatcher(matcher: TMatcherRecord) {
- matcherMap.delete(matcher.name)
- for (const child of matcher.children) {
- removeMatcher(child)
- }
- // TODO: delete from matchers
- // TODO: delete children and aliases
- }
-
- function clearMatchers() {
- matchers.splice(0, matchers.length)
- matcherMap.clear()
- }
-
- function getRecords() {
- return matchers
- }
-
- function getRecord(name: RecordName) {
- return matcherMap.get(name)
- }
-
- return {
- resolve,
-
- addMatcher,
- removeMatcher,
- clearMatchers,
- getRecord,
- getRecords,
- }
-}
-
-/**
- * Performs a binary search to find the correct insertion index for a new matcher.
- *
- * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships,
- * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes.
- *
- * @param matcher - new matcher to be inserted
- * @param matchers - existing matchers
- */
-
-export function findInsertionIndex<T extends NEW_MatcherDynamicRecord>(
- matcher: T,
- matchers: T[]
-) {
- // First phase: binary search based on score
- let lower = 0
- let upper = matchers.length
-
- while (lower !== upper) {
- const mid = (lower + upper) >> 1
- const sortOrder = comparePathParserScore(matcher, matchers[mid])
-
- if (sortOrder < 0) {
- upper = mid
- } else {
- lower = mid + 1
- }
- }
-
- // Second phase: check for an ancestor with the same score
- const insertionAncestor = getInsertionAncestor(matcher)
-
- if (insertionAncestor) {
- upper = matchers.lastIndexOf(insertionAncestor, upper - 1)
-
- if (__DEV__ && upper < 0) {
- // This should never happen
- warn(
- // TODO: fix stringifying new matchers
- `Finding ancestor route "${insertionAncestor.path}" failed for "${matcher.path}"`
- )
- }
- }
-
- return upper
-}
-export function getInsertionAncestor<T extends NEW_MatcherDynamicRecord>(
- matcher: T
-) {
- let ancestor: T | undefined = matcher
-
- while ((ancestor = ancestor.parent)) {
- if (!ancestor.group && comparePathParserScore(matcher, ancestor) === 0) {
- return ancestor
- }
- }
-
- return
-}
-
-/**
- * Checks if a record or any of its parent is an alias
- * @param record
- */
-export function isAliasRecord<T extends NEW_MatcherDynamicRecord>(
- record: T | undefined
-): boolean {
- while (record) {
- if (record.aliasOf) return true
- record = record.parent
- }
-
- return false
-} // pathEncoded`/users/${1}`
-// TODO:
-// pathEncoded`/users/${null}/end`
-// const a: RouteRecordRaw = {} as any
-/**
- * Build the `matched` array of a record that includes all parent records from the root to the current one.
- */
-
-export function buildMatched<T extends EXPERIMENTAL_ResolverRecord_Base>(
- record: T
-): T[] {
- const matched: T[] = []
- let node: T | undefined = record
- while (node) {
- matched.unshift(node)
- node = node.parent
- }
- return matched
-}
-export interface EXPERIMENTAL_ResolverRecord_Base {
- /**
- * Name of the matcher. Unique across all matchers.
- */
- name: RecordName
-
- /**
- * {@link MatcherPattern} for the path section of the URI.
- */
- path: MatcherPatternPath
-
- /**
- * {@link MatcherPattern} for the query section of the URI.
- */
- query?: MatcherPatternQuery
-
- /**
- * {@link MatcherPattern} for the hash section of the URI.
- */
- hash?: MatcherPatternHash
-
- // TODO: here or in router
- // redirect?: RouteRecordRedirectOption
- parent?: this
- // FIXME: this property is only needed for dynamic routing
- children: this[]
- aliasOf?: this
-
- /**
- * Is this a record that groups children. Cannot be matched
- */
- group?: boolean
-}
-export interface NEW_MatcherDynamicRecord
- extends EXPERIMENTAL_ResolverRecord_Base {
- // TODO: the score shouldn't be always needed, it's only needed with dynamic routing
- score: Array<number[]>
-} // FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc)
-/**
- * Experimental new matcher record base type.
- *
- * @experimental
- */
-
-export interface NEW_MatcherRecordRaw {
- path: MatcherPatternPath
- query?: MatcherPatternQuery
- hash?: MatcherPatternHash
-
- // NOTE: matchers do not handle `redirect` the redirect option, the router
- // does. They can still match the correct record but they will let the router
- // retrigger a whole navigation to the new location.
- // TODO: probably as `aliasOf`. Maybe a different format with the path, query and has matchers?
- /**
- * Aliases for the record. Allows defining extra paths that will behave like a
- * copy of the record. Allows having paths shorthands like `/users/:id` and
- * `/u/:id`. All `alias` and `path` values must share the same params.
- */
- // alias?: string | string[]
- /**
- * Name for the route record. Must be unique. Will be set to `Symbol()` if
- * not set.
- */
- name?: RecordName
-
- /**
- * Array of nested routes.
- */
- children?: NEW_MatcherRecordRaw[]
-
- /**
- * Is this a record that groups children. Cannot be matched
- */
- group?: boolean
-
- score: Array<number[]>
-}
-
-/**
- * Normalized version of a {@link NEW_MatcherRecordRaw} record.
- */
-export interface NEW_MatcherRecord extends NEW_MatcherDynamicRecord {}
ANY_HASH_PATTERN_MATCHER,
PAGE_QUERY_PATTERN_MATCHER,
} from './matchers/test-utils'
+import { miss } from './matchers/errors'
+import { MatcherPatternPath } from './matchers/matcher-pattern'
+
+// Additional pattern matchers for testing advanced scenarios
+const USERS_ID_OTHER_PATH_MATCHER: MatcherPatternPath<{
+ id: string
+ other: string
+}> = {
+ match(path) {
+ const match = path.match(/^\/users\/([^/]+)\/([^/]+)$/)
+ if (!match) throw miss()
+ return { id: match[1], other: match[2] }
+ },
+ build({ id, other }) {
+ return `/users/${id}/${other}`
+ },
+}
+
+const AB_PARAMS_PATH_MATCHER: MatcherPatternPath<{ a: string; b: string }> = {
+ match(path) {
+ const match = path.match(/^\/([^/]+)\/([^/]+)$/)
+ if (!match) throw miss()
+ return { a: match[1], b: match[2] }
+ },
+ build({ a, b }) {
+ return `/${a}/${b}`
+ },
+}
+
+const AB_OPTIONAL_PATH_MATCHER: MatcherPatternPath<{ a: string; b?: string }> =
+ {
+ match(path) {
+ const match = path.match(/^\/([^/]+)(?:\/([^/]+))?$/)
+ if (!match) throw miss()
+ return { a: match[1], b: match[2] || '' }
+ },
+ build({ a, b }) {
+ return b ? `/${a}/${b}` : `/${a}`
+ },
+ }
+
+const REPEATABLE_PARAM_MATCHER: MatcherPatternPath<{ p: string | string[] }> = {
+ match(path) {
+ const match = path.match(/^\/a\/(.+)$/)
+ if (!match) throw miss()
+ const segments = match[1].split('/')
+ return { p: segments.length === 1 ? segments[0] : segments }
+ },
+ build({ p }) {
+ const segments = Array.isArray(p) ? p : [p]
+ return `/a/${segments.join('/')}`
+ },
+}
describe('fixed resolver', () => {
describe('new matchers', () => {
})
})
- describe('relative locations as strings', () => {
- it('resolves a simple object relative location', () => {
+ describe('relative locations', () => {
+ it('resolves relative string locations', () => {
const resolver = createFixedResolver([
{ name: 'any-path', path: ANY_PATH_PATTERN_MATCHER },
])
+ const currentLocation = resolver.resolve({ path: '/nested/' })
+
+ expect(resolver.resolve('foo', currentLocation)).toMatchObject({
+ params: {},
+ path: '/nested/foo',
+ query: {},
+ hash: '',
+ })
+ expect(resolver.resolve('../foo', currentLocation)).toMatchObject({
+ params: {},
+ path: '/foo',
+ query: {},
+ hash: '',
+ })
+ expect(resolver.resolve('./foo', currentLocation)).toMatchObject({
+ params: {},
+ path: '/nested/foo',
+ query: {},
+ hash: '',
+ })
+ })
+
+ it('resolves relative object locations', () => {
+ const resolver = createFixedResolver([
+ { name: 'any-path', path: ANY_PATH_PATTERN_MATCHER },
+ ])
+
+ const currentLocation = resolver.resolve({ path: '/nested/' })
+
expect(
- resolver.resolve(
- { path: 'foo' },
- resolver.resolve({ path: '/nested/' })
- )
+ resolver.resolve({ path: 'foo' }, currentLocation)
).toMatchObject({
params: {},
path: '/nested/foo',
hash: '',
})
expect(
- resolver.resolve(
- { path: '../foo' },
- resolver.resolve({ path: '/nested/' })
- )
+ resolver.resolve({ path: '../foo' }, currentLocation)
).toMatchObject({
params: {},
path: '/foo',
hash: '',
})
expect(
- resolver.resolve(
- { path: './foo' },
- resolver.resolve({ path: '/nested/' })
- )
+ resolver.resolve({ path: './foo' }, currentLocation)
).toMatchObject({
params: {},
path: '/nested/foo',
hash: '',
})
})
- })
- it('resolves a simple string relative location', () => {
- const resolver = createFixedResolver([
- { name: 'any-path', path: ANY_PATH_PATTERN_MATCHER },
- ])
+ it('merges params with current location', () => {
+ const resolver = createFixedResolver([
+ { name: 'ab', path: AB_PARAMS_PATH_MATCHER },
+ ])
- expect(
- resolver.resolve('foo', resolver.resolve({ path: '/nested/' }))
- ).toMatchObject({
- params: {},
- path: '/nested/foo',
- query: {},
- hash: '',
+ const currentLocation = resolver.resolve({ path: '/A/B' })
+
+ expect(
+ resolver.resolve({ params: { b: 'b' } }, currentLocation)
+ ).toMatchObject({
+ name: 'ab',
+ path: '/A/b',
+ params: { a: 'A', b: 'b' },
+ })
})
- expect(
- resolver.resolve('../foo', resolver.resolve({ path: '/nested/' }))
- ).toMatchObject({
- params: {},
- path: '/foo',
- query: {},
- hash: '',
+
+ it('keeps params if not provided', () => {
+ const resolver = createFixedResolver([
+ { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER },
+ ])
+
+ const currentLocation = resolver.resolve({ path: '/users/ed/user' })
+
+ expect(resolver.resolve({}, currentLocation)).toMatchObject({
+ name: 'user-edit',
+ path: '/users/ed/user',
+ params: { id: 'ed', other: 'user' },
+ })
})
- expect(
- resolver.resolve('./foo', resolver.resolve({ path: '/nested/' }))
- ).toMatchObject({
- params: {},
- path: '/nested/foo',
- query: {},
- hash: '',
+
+ it('replaces params even with no name', () => {
+ const resolver = createFixedResolver([
+ { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER },
+ ])
+
+ const currentLocation = resolver.resolve({ path: '/users/ed/user' })
+
+ expect(
+ resolver.resolve(
+ { params: { id: 'posva', other: 'admin' } },
+ currentLocation
+ )
+ ).toMatchObject({
+ path: '/users/posva/admin',
+ })
})
})
describe('absolute locations', () => {
- it('resolves an object location', () => {
+ it('resolves an absolute string location', () => {
const resolver = createFixedResolver([
{ name: 'root', path: EMPTY_PATH_PATTERN_MATCHER },
])
- expect(resolver.resolve({ path: '/' })).toMatchObject({
+ expect(resolver.resolve('/')).toMatchObject({
fullPath: '/',
path: '/',
params: {},
})
})
- it('resolves an absolute string location', () => {
+ it('resolves an absolute object location', () => {
const resolver = createFixedResolver([
{ name: 'root', path: EMPTY_PATH_PATTERN_MATCHER },
])
- expect(resolver.resolve('/')).toMatchObject({
+ expect(resolver.resolve({ path: '/' })).toMatchObject({
fullPath: '/',
path: '/',
params: {},
])
// Object with path containing query/hash should treat entire string as pathname
expect(resolver.resolve({ path: '/?a=a&b=b#h' })).toMatchObject({
- path: '/?a=a&b=b#h', // Full string treated as path
- query: {}, // Empty query
- hash: '', // Empty hash
- params: { pathMatch: '/?a=a&b=b#h' }, // Matcher sees full string
+ path: '/?a=a&b=b#h',
+ query: {},
+ hash: '',
+ params: { pathMatch: '/?a=a&b=b#h' },
})
})
})
hash: '',
})
})
+
+ it('resolves named locations with params', () => {
+ const resolver = createFixedResolver([
+ { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER },
+ ])
+
+ expect(
+ resolver.resolve({
+ name: 'user-edit',
+ params: { id: 'posva', other: 'admin' },
+ })
+ ).toMatchObject({
+ name: 'user-edit',
+ path: '/users/posva/admin',
+ params: { id: 'posva', other: 'admin' },
+ })
+ })
+
+ it('throws if named route does not exist', () => {
+ const resolver = createFixedResolver([])
+
+ expect(() =>
+ resolver.resolve({ name: 'nonexistent', params: {} })
+ ).toThrowError('Record "nonexistent" not found')
+ })
})
describe('encoding', () => {
fullPath: '/%23%2F%3F',
path: '/%23%2F%3F',
query: {},
- // we don't tests params here becuase it's matcher's responsibility to encode the path
+ // we don't test params here because it's matcher's responsibility to encode the path
hash: '',
})
})
})
})
})
+
+ describe('multiple parameters', () => {
+ it('resolves paths with multiple params', () => {
+ const resolver = createFixedResolver([
+ { name: 'user', path: USERS_ID_OTHER_PATH_MATCHER },
+ { name: 'ab', path: AB_PARAMS_PATH_MATCHER },
+ ])
+
+ expect(resolver.resolve({ path: '/users/posva/hey' })).toMatchObject({
+ name: 'user',
+ params: { id: 'posva', other: 'hey' },
+ path: '/users/posva/hey',
+ })
+
+ expect(resolver.resolve({ path: '/foo/bar' })).toMatchObject({
+ name: 'ab',
+ params: { a: 'foo', b: 'bar' },
+ path: '/foo/bar',
+ })
+ })
+ })
+
+ describe('repeatable parameters', () => {
+ it('resolves array of params for repeatable params', () => {
+ const resolver = createFixedResolver([
+ { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER },
+ ])
+
+ expect(
+ resolver.resolve({
+ name: 'repeatable',
+ params: { p: ['b', 'c', 'd'] },
+ })
+ ).toMatchObject({
+ name: 'repeatable',
+ path: '/a/b/c/d',
+ params: { p: ['b', 'c', 'd'] },
+ })
+ })
+
+ it('resolves single param for repeatable params', () => {
+ const resolver = createFixedResolver([
+ { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER },
+ ])
+
+ expect(
+ resolver.resolve({ name: 'repeatable', params: { p: 'b' } })
+ ).toMatchObject({
+ name: 'repeatable',
+ path: '/a/b',
+ params: { p: 'b' },
+ })
+ })
+
+ it('keeps repeated params as array when provided through path', () => {
+ const resolver = createFixedResolver([
+ { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER },
+ ])
+
+ expect(resolver.resolve({ path: '/a/b/c' })).toMatchObject({
+ name: 'repeatable',
+ params: { p: ['b', 'c'] },
+ })
+ })
+ })
+
+ describe('optional parameters', () => {
+ it('handles optional trailing param', () => {
+ const resolver = createFixedResolver([
+ { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER },
+ ])
+
+ expect(resolver.resolve({ path: '/foo' })).toMatchObject({
+ name: 'optional',
+ params: { a: 'foo', b: '' },
+ path: '/foo',
+ })
+
+ expect(resolver.resolve({ path: '/foo/bar' })).toMatchObject({
+ name: 'optional',
+ params: { a: 'foo', b: 'bar' },
+ path: '/foo/bar',
+ })
+ })
+
+ it('drops optional params in named location', () => {
+ const resolver = createFixedResolver([
+ { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER },
+ ])
+
+ expect(
+ resolver.resolve({ name: 'optional', params: { a: 'b' } })
+ ).toMatchObject({
+ name: 'optional',
+ path: '/b',
+ params: { a: 'b' },
+ })
+ })
+
+ it('keeps optional params passed as empty strings', () => {
+ const resolver = createFixedResolver([
+ { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER },
+ ])
+
+ expect(
+ resolver.resolve({ name: 'optional', params: { a: 'b', b: '' } })
+ ).toMatchObject({
+ name: 'optional',
+ path: '/b',
+ params: { a: 'b', b: '' },
+ })
+ })
+ })
+
+ it('has strict trailing slash handling', () => {
+ const resolver = createFixedResolver([
+ { name: 'home', path: new MatcherPatternPathStatic('/home') },
+ { name: 'home-slash', path: new MatcherPatternPathStatic('/home/') },
+ ])
+
+ expect(resolver.resolve({ path: '/home' })).toMatchObject({
+ name: 'home',
+ path: '/home',
+ })
+
+ expect(resolver.resolve({ path: '/home/' })).toMatchObject({
+ name: 'home-slash',
+ path: '/home/',
+ })
+ })
+
+ describe('nested routes', () => {
+ it('resolves child routes with parent-child relationships', () => {
+ const parentRecord = {
+ name: 'parent',
+ path: new MatcherPatternPathStatic('/parent'),
+ parent: null,
+ }
+
+ const childRecord = {
+ name: 'child',
+ path: new MatcherPatternPathStatic('/child'),
+ parent: parentRecord,
+ }
+
+ const resolver = createFixedResolver([parentRecord, childRecord])
+
+ expect(resolver.resolve({ path: '/child' })).toMatchObject({
+ name: 'child',
+ path: '/child',
+ matched: [parentRecord, childRecord],
+ })
+ })
+
+ it('resolves child routes with params', () => {
+ const parentRecord = {
+ name: 'users',
+ path: new MatcherPatternPathStatic('/users'),
+ parent: null,
+ }
+
+ const childRecord = {
+ name: 'user-detail',
+ path: USER_ID_PATH_PATTERN_MATCHER,
+ parent: parentRecord,
+ }
+
+ const resolver = createFixedResolver([parentRecord, childRecord])
+
+ expect(resolver.resolve({ path: '/users/123' })).toMatchObject({
+ name: 'user-detail',
+ params: { id: 123 },
+ matched: [parentRecord, childRecord],
+ })
+ })
+ })
})
})