]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
test: more resolver tests
authorEduardo San Martin Morote <posva13@gmail.com>
Sun, 17 Aug 2025 15:19:19 +0000 (17:19 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Sun, 17 Aug 2025 15:19:19 +0000 (17:19 +0200)
packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts [deleted file]
packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts [deleted file]
packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts [deleted file]
packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts

diff --git a/packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts b/packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts
deleted file mode 100644 (file)
index 7b1720c..0000000
+++ /dev/null
@@ -1,1002 +0,0 @@
-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` }],
-        }
-      )
-    })
-  })
-})
diff --git a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts b/packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts
deleted file mode 100644 (file)
index e249fe2..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-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>
-    )
-  })
-})
diff --git a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts b/packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts
deleted file mode 100644 (file)
index 1fc4cfc..0000000
+++ /dev/null
@@ -1,475 +0,0 @@
-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 {}
index 1050f66840850a9f1b83fa3255e8b089ebeba74f..144eae296343cb87f2ff4f79d9c9e067e5129942 100644 (file)
@@ -13,6 +13,59 @@ import {
   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', () => {
@@ -154,17 +207,43 @@ describe('fixed resolver', () => {
       })
     })
 
-    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',
@@ -173,10 +252,7 @@ describe('fixed resolver', () => {
           hash: '',
         })
         expect(
-          resolver.resolve(
-            { path: '../foo' },
-            resolver.resolve({ path: '/nested/' })
-          )
+          resolver.resolve({ path: '../foo' }, currentLocation)
         ).toMatchObject({
           params: {},
           path: '/foo',
@@ -185,10 +261,7 @@ describe('fixed resolver', () => {
           hash: '',
         })
         expect(
-          resolver.resolve(
-            { path: './foo' },
-            resolver.resolve({ path: '/nested/' })
-          )
+          resolver.resolve({ path: './foo' }, currentLocation)
         ).toMatchObject({
           params: {},
           path: '/nested/foo',
@@ -197,45 +270,61 @@ describe('fixed resolver', () => {
           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: {},
@@ -244,11 +333,11 @@ describe('fixed resolver', () => {
         })
       })
 
-      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: {},
@@ -263,10 +352,10 @@ describe('fixed resolver', () => {
         ])
         // 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' },
         })
       })
     })
@@ -288,6 +377,31 @@ describe('fixed resolver', () => {
           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', () => {
@@ -300,7 +414,7 @@ describe('fixed resolver', () => {
             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: '',
           })
         })
@@ -362,5 +476,181 @@ describe('fixed resolver', () => {
         })
       })
     })
+
+    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],
+        })
+      })
+    })
   })
 })