]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: simplify router resolve
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 23 Dec 2024 14:28:35 +0000 (15:28 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 23 Dec 2024 14:28:35 +0000 (15:28 +0100)
packages/router/src/experimental/router.ts
packages/router/src/new-route-resolver/matcher-pattern.ts
packages/router/src/new-route-resolver/matcher-resolve.spec.ts
packages/router/src/new-route-resolver/matcher.spec.ts
packages/router/src/new-route-resolver/matcher.test-d.ts
packages/router/src/new-route-resolver/matcher.ts
packages/router/src/new-route-resolver/matchers/test-utils.ts
packages/router/src/types/typeGuards.ts

index cc73bc9c38f6fbe8553580025e0990bc8828e335..3c79d2c696697dd1c17e75dcf3e529eb81dfbb40 100644 (file)
@@ -9,11 +9,11 @@ import {
 import {
   nextTick,
   shallowReactive,
+  ShallowRef,
   shallowRef,
   unref,
   warn,
   type App,
-  type Ref,
 } from 'vue'
 import { RouterLink } from '../RouterLink'
 import { RouterView } from '../RouterView'
@@ -23,10 +23,13 @@ import {
   type RouterHistory,
 } from '../history/common'
 import type { PathParserOptions } from '../matcher'
-import type { RouteResolver } from '../new-route-resolver/matcher'
+import type {
+  NEW_LocationResolved,
+  NEW_MatcherRecord,
+  NEW_MatcherRecordRaw,
+  NEW_RouterMatcher,
+} from '../new-route-resolver/matcher'
 import {
-  LocationQuery,
-  normalizeQuery,
   parseQuery as originalParseQuery,
   stringifyQuery as originalStringifyQuery,
 } from '../query'
@@ -48,6 +51,7 @@ import type {
   RouteLocationAsRelative,
   RouteLocationAsRelativeTyped,
   RouteLocationAsString,
+  RouteLocationGeneric,
   RouteLocationNormalized,
   RouteLocationNormalizedLoaded,
   RouteLocationRaw,
@@ -60,19 +64,17 @@ import {
   isRouteLocation,
   isRouteName,
   Lazy,
-  MatcherLocationRaw,
   RouteLocationOptions,
-  type RouteRecordRaw,
+  RouteMeta,
 } from '../types'
 import { useCallbacks } from '../utils/callbacks'
 import {
   isSameRouteLocation,
   parseURL,
   START_LOCATION_NORMALIZED,
-  stringifyURL,
 } from '../location'
 import { applyToParams, assign, isArray, isBrowser, noop } from '../utils'
-import { decode, encodeHash, encodeParam } from '../encoding'
+import { decode, encodeParam } from '../encoding'
 import {
   extractChangingRecords,
   extractComponentsGuards,
@@ -177,18 +179,19 @@ export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
  * Options to initialize an experimental {@link EXPERIMENTAL_Router} instance.
  * @experimental
  */
-export interface EXPERIMENTAL_RouterOptions<TRouteRecordRaw, TRouteRecord>
-  extends EXPERIMENTAL_RouterOptions_Base {
+export interface EXPERIMENTAL_RouterOptions<
+  TMatcherRecord extends NEW_MatcherRecord
+> extends EXPERIMENTAL_RouterOptions_Base {
   /**
    * Initial list of routes that should be added to the router.
    */
-  routes?: Readonly<RouteRecordRaw[]>
+  routes?: Readonly<EXPERIMENTAL_RouteRecordRaw[]>
 
   /**
    * Matcher to use to resolve routes.
    * @experimental
    */
-  matcher: RouteResolver<TRouteRecordRaw, TRouteRecord>
+  matcher: NEW_RouterMatcher<NEW_MatcherRecordRaw, TMatcherRecord>
 }
 
 /**
@@ -199,7 +202,7 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   /**
    * Current {@link RouteLocationNormalized}
    */
-  readonly currentRoute: Ref<RouteLocationNormalizedLoaded>
+  readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>
 
   /**
    * Allows turning off the listening of history events. This is a low level api for micro-frontend.
@@ -207,7 +210,7 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   listening: boolean
 
   /**
-   * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
+   * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} as the child of an existing route.
    *
    * @param parentName - Parent Route Record where `route` should be appended at
    * @param route - Route Record to add
@@ -215,10 +218,10 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   addRoute(
     // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
     parentName: NonNullable<RouteRecordNameGeneric>,
-    route: RouteRecordRaw
+    route: TRouteRecordRaw
   ): () => void
   /**
-   * Add a new {@link RouteRecordRaw | route record} to the router.
+   * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} to the router.
    *
    * @param route - Route Record to add
    */
@@ -385,23 +388,45 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   install(app: App): void
 }
 
-export interface EXPERIMENTAL_Router<TRouteRecordRaw, TRouteRecord>
-  extends EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
+export interface EXPERIMENTAL_Router<
+  TRouteRecordRaw, // extends NEW_MatcherRecordRaw,
+  TRouteRecord extends NEW_MatcherRecord
+> extends EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   /**
    * Original options object passed to create the Router
    */
-  readonly options: EXPERIMENTAL_RouterOptions<TRouteRecordRaw, TRouteRecord>
+  readonly options: EXPERIMENTAL_RouterOptions<TRouteRecord>
+}
+
+export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw {
+  /**
+   * Arbitrary data attached to the record.
+   */
+  meta?: RouteMeta
+}
+
+// TODO: is it worth to have 2 types for the undefined values?
+export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord {
+  meta: RouteMeta
 }
 
-interface EXPERIMENTAL_RouteRecordRaw {}
-interface EXPERIMENTAL_RouteRecord {}
+function normalizeRouteRecord(
+  record: EXPERIMENTAL_RouteRecordRaw
+): EXPERIMENTAL_RouteRecordNormalized {
+  // FIXME: implementation
+  return {
+    name: __DEV__ ? Symbol('anonymous route record') : Symbol(),
+    meta: {},
+    ...record,
+  }
+}
 
 export function experimental_createRouter(
-  options: EXPERIMENTAL_RouterOptions<
-    EXPERIMENTAL_RouteRecordRaw,
-    EXPERIMENTAL_RouteRecord
-  >
-): EXPERIMENTAL_Router<EXPERIMENTAL_RouteRecordRaw, EXPERIMENTAL_RouteRecord> {
+  options: EXPERIMENTAL_RouterOptions<EXPERIMENTAL_RouteRecordNormalized>
+): EXPERIMENTAL_Router<
+  EXPERIMENTAL_RouteRecordRaw,
+  EXPERIMENTAL_RouteRecordNormalized
+> {
   const {
     matcher,
     parseQuery = originalParseQuery,
@@ -438,11 +463,14 @@ export function experimental_createRouter(
     applyToParams.bind(null, decode)
 
   function addRoute(
-    parentOrRoute: NonNullable<RouteRecordNameGeneric> | RouteRecordRaw,
-    route?: RouteRecordRaw
+    parentOrRoute:
+      | NonNullable<RouteRecordNameGeneric>
+      | EXPERIMENTAL_RouteRecordRaw,
+    route?: EXPERIMENTAL_RouteRecordRaw
   ) {
     let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined
-    let record: RouteRecordRaw
+    let rawRecord: EXPERIMENTAL_RouteRecordRaw
+
     if (isRouteName(parentOrRoute)) {
       parent = matcher.getMatcher(parentOrRoute)
       if (__DEV__ && !parent) {
@@ -453,12 +481,19 @@ export function experimental_createRouter(
           route
         )
       }
-      record = route!
+      rawRecord = route!
     } else {
-      record = parentOrRoute
+      rawRecord = parentOrRoute
     }
 
-    return matcher.addRoute(record, parent)
+    const addedRecord = matcher.addRoute(
+      normalizeRouteRecord(rawRecord),
+      parent
+    )
+
+    return () => {
+      matcher.removeRoute(addedRecord)
+    }
   }
 
   function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
@@ -471,7 +506,7 @@ export function experimental_createRouter(
   }
 
   function getRoutes() {
-    return matcher.getMatchers().map(routeMatcher => routeMatcher.record)
+    return matcher.getMatchers()
   }
 
   function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
@@ -485,139 +520,66 @@ export function experimental_createRouter(
     // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => {
     // const objectLocation = routerLocationAsObject(rawLocation)
     // we create a copy to modify it later
-    currentLocation = assign({}, currentLocation || currentRoute.value)
-    if (typeof rawLocation === 'string') {
-      const locationNormalized = parseURL(
-        parseQuery,
-        rawLocation,
-        currentLocation.path
-      )
-      const matchedRoute = matcher.resolve(
-        { path: locationNormalized.path },
-        currentLocation
-      )
+    // TODO: in the experimental version, allow configuring this
+    currentLocation =
+      currentLocation && assign({}, currentLocation || currentRoute.value)
+    // currentLocation = assign({}, currentLocation || currentRoute.value)
 
-      const href = routerHistory.createHref(locationNormalized.fullPath)
-      if (__DEV__) {
-        if (href.startsWith('//'))
-          warn(
-            `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
-          )
-        else if (!matchedRoute.matched.length) {
-          warn(`No match found for location with path "${rawLocation}"`)
-        }
+    if (__DEV__) {
+      if (!isRouteLocation(rawLocation)) {
+        warn(
+          `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
+          rawLocation
+        )
+        return resolve({})
       }
 
-      // locationNormalized is always a new object
-      return assign(locationNormalized, matchedRoute, {
-        params: decodeParams(matchedRoute.params),
-        hash: decode(locationNormalized.hash),
-        redirectedFrom: undefined,
-        href,
-      })
-    }
-
-    if (__DEV__ && !isRouteLocation(rawLocation)) {
-      warn(
-        `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
-        rawLocation
-      )
-      return resolve({})
-    }
-
-    let matcherLocation: MatcherLocationRaw
-
-    // path could be relative in object as well
-    if (rawLocation.path != null) {
       if (
-        __DEV__ &&
-        'params' in rawLocation &&
-        !('name' in rawLocation) &&
-        // @ts-expect-error: the type is never
-        Object.keys(rawLocation.params).length
+        typeof rawLocation === 'object' &&
+        rawLocation.hash?.startsWith('#')
       ) {
         warn(
-          `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`
+          `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
         )
       }
-      matcherLocation = assign({}, rawLocation, {
-        path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path,
-      })
-    } else {
-      // remove any nullish param
-      const targetParams = assign({}, rawLocation.params)
-      for (const key in targetParams) {
-        if (targetParams[key] == null) {
-          delete targetParams[key]
-        }
-      }
-      // pass encoded values to the matcher, so it can produce encoded path and fullPath
-      matcherLocation = assign({}, rawLocation, {
-        params: encodeParams(targetParams),
-      })
-      // current location params are decoded, we need to encode them in case the
-      // matcher merges the params
-      currentLocation.params = encodeParams(currentLocation.params)
     }
 
-    const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
-    const hash = rawLocation.hash || ''
-
-    if (__DEV__ && hash && !hash.startsWith('#')) {
-      warn(
-        `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
-      )
-    }
-
-    // the matcher might have merged current location params, so
-    // we need to run the decoding again
-    matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params))
-
-    const fullPath = stringifyURL(
-      stringifyQuery,
-      assign({}, rawLocation, {
-        hash: encodeHash(hash),
-        path: matchedRoute.path,
-      })
+    // FIXME: is this achieved by matchers?
+    // remove any nullish param
+    // if ('params' in rawLocation) {
+    //   const targetParams = assign({}, rawLocation.params)
+    //   for (const key in targetParams) {
+    //     if (targetParams[key] == null) {
+    //       delete targetParams[key]
+    //     }
+    //   }
+    //   rawLocation.params = targetParams
+    // }
+
+    const matchedRoute = matcher.resolve(
+      rawLocation,
+      currentLocation satisfies NEW_LocationResolved<EXPERIMENTAL_RouteRecordNormalized>
     )
+    const href = routerHistory.createHref(matchedRoute.fullPath)
 
-    const href = routerHistory.createHref(fullPath)
     if (__DEV__) {
       if (href.startsWith('//')) {
         warn(
           `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
         )
-      } else if (!matchedRoute.matched.length) {
-        warn(
-          `No match found for location with path "${
-            rawLocation.path != null ? rawLocation.path : rawLocation
-          }"`
-        )
+      }
+      if (!matchedRoute.matched.length) {
+        warn(`No match found for location with path "${rawLocation}"`)
       }
     }
 
-    return assign(
-      {
-        fullPath,
-        // keep the hash encoded so fullPath is effectively path + encodedQuery +
-        // hash
-        hash,
-        query:
-          // if the user is using a custom query lib like qs, we might have
-          // nested objects, so we keep the query as is, meaning it can contain
-          // numbers at `$route.query`, but at the point, the user will have to
-          // use their own type anyway.
-          // https://github.com/vuejs/router/issues/328#issuecomment-649481567
-          stringifyQuery === originalStringifyQuery
-            ? normalizeQuery(rawLocation.query)
-            : ((rawLocation.query || {}) as LocationQuery),
-      },
-      matchedRoute,
-      {
-        redirectedFrom: undefined,
-        href,
-      }
-    )
+    // TODO: can this be refactored at the very end
+    // matchedRoute is always a new object
+    return assign(matchedRoute, {
+      redirectedFrom: undefined,
+      href,
+      meta: mergeMetaFields(matchedRoute.matched),
+    })
   }
 
   function locationAsObject(
@@ -648,7 +610,7 @@ export function experimental_createRouter(
   }
 
   function replace(to: RouteLocationRaw) {
-    return push(assign(locationAsObject(to), { replace: true }))
+    return pushWithRedirect(to, true)
   }
 
   function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void {
@@ -700,14 +662,14 @@ export function experimental_createRouter(
 
   function pushWithRedirect(
     to: RouteLocationRaw | RouteLocation,
+    _replace?: boolean,
     redirectedFrom?: RouteLocation
   ): Promise<NavigationFailure | void | undefined> {
     const targetLocation: RouteLocation = (pendingLocation = resolve(to))
     const from = currentRoute.value
     const data: HistoryState | undefined = (to as RouteLocationOptions).state
     const force: boolean | undefined = (to as RouteLocationOptions).force
-    // to could be a string where `replace` is a function
-    const replace = (to as RouteLocationOptions).replace === true
+    const replace = (to as RouteLocationOptions).replace ?? _replace
 
     const shouldRedirect = handleRedirectRecord(targetLocation)
 
@@ -719,8 +681,8 @@ export function experimental_createRouter(
               ? assign({}, data, shouldRedirect.state)
               : data,
           force,
-          replace,
         }),
+        replace,
         // keep original redirectedFrom if it exists
         redirectedFrom || targetLocation
       )
@@ -790,20 +752,15 @@ export function experimental_createRouter(
 
             return pushWithRedirect(
               // keep options
-              assign(
-                {
-                  // preserve an existing replacement but allow the redirect to override it
-                  replace,
-                },
-                locationAsObject(failure.to),
-                {
-                  state:
-                    typeof failure.to === 'object'
-                      ? assign({}, data, failure.to.state)
-                      : data,
-                  force,
-                }
-              ),
+              assign(locationAsObject(failure.to), {
+                state:
+                  typeof failure.to === 'object'
+                    ? assign({}, data, failure.to.state)
+                    : data,
+                force,
+              }),
+              // preserve an existing replacement but allow the redirect to override it
+              replace,
               // preserve the original redirectedFrom if any
               redirectedFrom || toLocation
             )
@@ -842,6 +799,7 @@ export function experimental_createRouter(
 
   function runWithContext<T>(fn: () => T): T {
     const app: App | undefined = installedApps.values().next().value
+    // TODO: remove safeguard and bump required minimum version of Vue
     // support Vue < 3.3
     return app && typeof app.runWithContext === 'function'
       ? app.runWithContext(fn)
@@ -1044,7 +1002,8 @@ export function experimental_createRouter(
       const shouldRedirect = handleRedirectRecord(toLocation)
       if (shouldRedirect) {
         pushWithRedirect(
-          assign(shouldRedirect, { replace: true, force: true }),
+          assign(shouldRedirect, { force: true }),
+          true,
           toLocation
         ).catch(noop)
         return
@@ -1088,6 +1047,7 @@ export function experimental_createRouter(
               assign(locationAsObject((error as NavigationRedirectError).to), {
                 force: true,
               }),
+              undefined,
               toLocation
               // avoid an uncaught rejection, let push call triggerError
             )
@@ -1250,7 +1210,10 @@ export function experimental_createRouter(
   let started: boolean | undefined
   const installedApps = new Set<App>()
 
-  const router: Router = {
+  const router: EXPERIMENTAL_Router<
+    EXPERIMENTAL_RouteRecordRaw,
+    EXPERIMENTAL_RouteRecordNormalized
+  > = {
     currentRoute,
     listening: true,
 
@@ -1280,6 +1243,7 @@ export function experimental_createRouter(
       app.component('RouterLink', RouterLink)
       app.component('RouterView', RouterView)
 
+      // @ts-expect-error: FIXME: refactor with new types once it's possible
       app.config.globalProperties.$router = router
       Object.defineProperty(app.config.globalProperties, '$route', {
         enumerable: true,
@@ -1311,6 +1275,7 @@ export function experimental_createRouter(
         })
       }
 
+      // @ts-expect-error: FIXME: refactor with new types once it's possible
       app.provide(routerKey, router)
       app.provide(routeLocationKey, shallowReactive(reactiveRoute))
       app.provide(routerViewLocationKey, currentRoute)
@@ -1334,6 +1299,7 @@ export function experimental_createRouter(
 
       // TODO: this probably needs to be updated so it can be used by vue-termui
       if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) {
+        // @ts-expect-error: FIXME: refactor with new types once it's possible
         addDevtools(app, router, matcher)
       }
     },
@@ -1349,3 +1315,14 @@ export function experimental_createRouter(
 
   return router
 }
+
+/**
+ * Merge meta fields of an array of records
+ *
+ * @param matched - array of matched records
+ */
+function mergeMetaFields(
+  matched: NEW_LocationResolved<EXPERIMENTAL_RouteRecordNormalized>['matched']
+): RouteMeta {
+  return assign({} as RouteMeta, ...matched.map(r => r.meta))
+}
index ad582bb8d4f1b9052be29b9b70e966d742d0468e..c627c3bffa853b4add402096a3828a17efbbf18b 100644 (file)
@@ -1,20 +1,7 @@
-import { decode, MatcherName, MatcherQueryParams } from './matcher'
+import { decode, MatcherQueryParams } from './matcher'
 import { EmptyParams, MatcherParamsFormatted } from './matcher-location'
 import { miss } from './matchers/errors'
 
-export interface MatcherPattern {
-  /**
-   * Name of the matcher. Unique across all matchers.
-   */
-  name: MatcherName
-
-  path: MatcherPatternPath
-  query?: MatcherPatternQuery
-  hash?: MatcherPatternHash
-
-  parent?: MatcherPattern
-}
-
 export interface MatcherPatternParams_Base<
   TIn = string,
   TOut extends MatcherParamsFormatted = MatcherParamsFormatted
index b4799bbece8f9e1b9dd4419eab8dd1b0b5e23c66..91fb8fb2442692ee3fa4433b78860b83b5807e04 100644 (file)
@@ -8,7 +8,7 @@ import { mockWarn } from '../../__tests__/vitest-mock-warn'
 import {
   createCompiledMatcher,
   MatcherLocationRaw,
-  MatcherRecordRaw,
+  NEW_MatcherRecordRaw,
   NEW_LocationResolved,
 } from './matcher'
 import { PathParams, tokensToParser } from '../matcher/pathParserRanker'
@@ -24,7 +24,7 @@ const components = { default: component }
 function compileRouteRecord(
   record: RouteRecordRaw,
   parentRecord?: RouteRecordRaw
-): MatcherRecordRaw {
+): NEW_MatcherRecordRaw {
   // we adapt the path to ensure they are absolute
   // TODO: aliases? they could be handled directly in the path matcher
   const path = record.path.startsWith('/')
@@ -100,7 +100,7 @@ describe('RouterMatcher.resolve', () => {
       | `/${string}` = START_LOCATION
   ) {
     const records = (Array.isArray(record) ? record : [record]).map(
-      (record): MatcherRecordRaw => compileRouteRecord(record)
+      (record): NEW_MatcherRecordRaw => compileRouteRecord(record)
     )
     const matcher = createCompiledMatcher()
     for (const record of records) {
index 22fb3e51163f783383906d54e2dc440cefb56080..07695b5987d8c37adb71716576db81428c08714f 100644 (file)
@@ -6,12 +6,12 @@ import {
 } from './matcher'
 import {
   MatcherPatternParams_Base,
-  MatcherPattern,
   MatcherPatternPath,
   MatcherPatternQuery,
   MatcherPatternPathStatic,
   MatcherPatternPathDynamic,
 } from './matcher-pattern'
+import { NEW_MatcherRecord } from './matcher'
 import { miss } from './matchers/errors'
 import { EmptyParams } from './matcher-location'
 
@@ -72,12 +72,17 @@ const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base<
 const EMPTY_PATH_ROUTE = {
   name: 'no params',
   path: EMPTY_PATH_PATTERN_MATCHER,
-} satisfies MatcherPattern
+} satisfies NEW_MatcherRecord
+
+const ANY_PATH_ROUTE = {
+  name: 'any path',
+  path: ANY_PATH_PATTERN_MATCHER,
+} satisfies NEW_MatcherRecord
 
 const USER_ID_ROUTE = {
   name: 'user-id',
   path: USER_ID_PATH_PATTERN_MATCHER,
-} satisfies MatcherPattern
+} satisfies NEW_MatcherRecord
 
 describe('RouterMatcher', () => {
   describe('new matchers', () => {
@@ -135,6 +140,20 @@ describe('RouterMatcher', () => {
       const matcher = createCompiledMatcher()
       matcher.addRoute(USER_ID_ROUTE)
     })
+
+    it('removes static path', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(EMPTY_PATH_ROUTE)
+      matcher.removeRoute(EMPTY_PATH_ROUTE)
+      // Add assertions to verify the route was removed
+    })
+
+    it('removes dynamic path', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(USER_ID_ROUTE)
+      matcher.removeRoute(USER_ID_ROUTE)
+      // Add assertions to verify the route was removed
+    })
   })
 
   describe('resolve()', () => {
@@ -293,5 +312,37 @@ describe('RouterMatcher', () => {
         })
       })
     })
+
+    describe('encoding', () => {
+      it('handles encoded string path', () => {
+        const matcher = createCompiledMatcher([ANY_PATH_ROUTE])
+        console.log(matcher.resolve('/%23%2F%3F'))
+        expect(matcher.resolve('/%23%2F%3F')).toMatchObject({
+          fullPath: '/%23%2F%3F',
+          path: '/%23%2F%3F',
+          query: {},
+          params: {},
+          hash: '',
+        })
+      })
+
+      it('decodes query from a string', () => {
+        const matcher = createCompiledMatcher([ANY_PATH_ROUTE])
+        expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({
+          path: '/foo',
+          fullPath: '/foo?foo=%23%2F%3F',
+          query: { foo: '#/?' },
+        })
+      })
+
+      it('decodes hash from a string', () => {
+        const matcher = createCompiledMatcher([ANY_PATH_ROUTE])
+        expect(matcher.resolve('/foo#h-%23%2F%3F')).toMatchObject({
+          path: '/foo',
+          fullPath: '/foo#h-%23%2F%3F',
+          hash: '#h-#/?',
+        })
+      })
+    })
   })
 })
index a60874518a3812312d901fb6e776bd27234d2d64..8ea5b771d7fc76acc9ab733947fd6e285441dc63 100644 (file)
@@ -1,14 +1,23 @@
 import { describe, expectTypeOf, it } from 'vitest'
-import { NEW_LocationResolved, RouteResolver } from './matcher'
+import {
+  NEW_LocationResolved,
+  NEW_MatcherRecordRaw,
+  NEW_RouterMatcher,
+} from './matcher'
+import { EXPERIMENTAL_RouteRecordNormalized } from '../experimental/router'
 
 describe('Matcher', () => {
-  const matcher: RouteResolver<unknown, unknown> = {} as any
+  type TMatcherRecordRaw = NEW_MatcherRecordRaw
+  type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized
+
+  const matcher: NEW_RouterMatcher<TMatcherRecordRaw, TMatcherRecord> =
+    {} as any
 
   describe('matcher.resolve()', () => {
     it('resolves absolute string locations', () => {
-      expectTypeOf(
-        matcher.resolve('/foo')
-      ).toEqualTypeOf<NEW_LocationResolved>()
+      expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf<
+        NEW_LocationResolved<TMatcherRecord>
+      >()
     })
 
     it('fails on non absolute location without a currentLocation', () => {
@@ -18,14 +27,14 @@ describe('Matcher', () => {
 
     it('resolves relative locations', () => {
       expectTypeOf(
-        matcher.resolve('foo', {} as NEW_LocationResolved)
-      ).toEqualTypeOf<NEW_LocationResolved>()
+        matcher.resolve('foo', {} as NEW_LocationResolved<TMatcherRecord>)
+      ).toEqualTypeOf<NEW_LocationResolved<TMatcherRecord>>()
     })
 
     it('resolved named locations', () => {
-      expectTypeOf(
-        matcher.resolve({ name: 'foo', params: {} })
-      ).toEqualTypeOf<NEW_LocationResolved>()
+      expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf<
+        NEW_LocationResolved<TMatcherRecord>
+      >()
     })
 
     it('fails on object relative location without a currentLocation', () => {
@@ -35,8 +44,11 @@ describe('Matcher', () => {
 
     it('resolves object relative locations with a currentLocation', () => {
       expectTypeOf(
-        matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved)
-      ).toEqualTypeOf<NEW_LocationResolved>()
+        matcher.resolve(
+          { params: { id: 1 } },
+          {} as NEW_LocationResolved<TMatcherRecord>
+        )
+      ).toEqualTypeOf<NEW_LocationResolved<TMatcherRecord>>()
     })
   })
 
index 54ea4cba128e2805ffc69ba792acac989a28465c..69ddc5540812d25845859eb4e616f33be67581d0 100644 (file)
@@ -5,7 +5,6 @@ import {
   stringifyQuery,
 } from '../query'
 import type {
-  MatcherPattern,
   MatcherPatternHash,
   MatcherPatternPath,
   MatcherPatternQuery,
@@ -20,6 +19,7 @@ import type {
   MatcherLocationAsRelative,
   MatcherParamsFormatted,
 } from './matcher-location'
+import { _RouteRecordProps } from '../typed-routes'
 
 /**
  * Allowed types for a matcher name.
@@ -28,12 +28,17 @@ export type MatcherName = string | symbol
 
 /**
  * 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 addRoute}.
+ * `TMatcherRecord` represents the normalized record type.
  */
-export interface RouteResolver<Matcher, MatcherNormalized> {
+export interface NEW_RouterMatcher<TMatcherRecordRaw, TMatcherRecord> {
   /**
    * Resolves an absolute location (like `/path/to/somewhere`).
    */
-  resolve(absoluteLocation: `/${string}`): NEW_LocationResolved
+  resolve(
+    absoluteLocation: `/${string}`,
+    currentLocation?: undefined | NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TMatcherRecord>
 
   /**
    * Resolves a string location relative to another location. A relative location can be `./same-folder`,
@@ -41,24 +46,28 @@ export interface RouteResolver<Matcher, MatcherNormalized> {
    */
   resolve(
     relativeLocation: string,
-    currentLocation: NEW_LocationResolved
-  ): NEW_LocationResolved
+    currentLocation: NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TMatcherRecord>
 
   /**
    * Resolves a location by its name. Any required params or query must be passed in the `options` argument.
    */
-  resolve(location: MatcherLocationAsNamed): NEW_LocationResolved
+  resolve(
+    location: MatcherLocationAsNamed
+  ): NEW_LocationResolved<TMatcherRecord>
 
   /**
    * Resolves a location by its absolute path (starts with `/`). Any required query must be passed.
    * @param location - The location to resolve.
    */
-  resolve(location: MatcherLocationAsPathAbsolute): NEW_LocationResolved
+  resolve(
+    location: MatcherLocationAsPathAbsolute
+  ): NEW_LocationResolved<TMatcherRecord>
 
   resolve(
     location: MatcherLocationAsPathRelative,
-    currentLocation: NEW_LocationResolved
-  ): NEW_LocationResolved
+    currentLocation: NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TMatcherRecord>
 
   // NOTE: in practice, this overload can cause bugs. It's better to use named locations
 
@@ -68,42 +77,28 @@ export interface RouteResolver<Matcher, MatcherNormalized> {
    */
   resolve(
     relativeLocation: MatcherLocationAsRelative,
-    currentLocation: NEW_LocationResolved
-  ): NEW_LocationResolved
+    currentLocation: NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TMatcherRecord>
 
-  addRoute(matcher: Matcher, parent?: MatcherNormalized): MatcherNormalized
-  removeRoute(matcher: MatcherNormalized): void
+  addRoute(matcher: TMatcherRecordRaw, parent?: TMatcherRecord): TMatcherRecord
+  removeRoute(matcher: TMatcherRecord): void
   clearRoutes(): void
 
   /**
    * Get a list of all matchers.
    * Previously named `getRoutes()`
    */
-  getMatchers(): MatcherNormalized[]
+  getMatchers(): TMatcherRecord[]
 
   /**
    * Get a matcher by its name.
    * Previously named `getRecordMatcher()`
    */
-  getMatcher(name: MatcherName): MatcherNormalized | undefined
+  getMatcher(name: MatcherName): TMatcherRecord | undefined
 }
 
-type MatcherResolveArgs =
-  | [absoluteLocation: `/${string}`]
-  | [relativeLocation: string, currentLocation: NEW_LocationResolved]
-  | [absoluteLocation: MatcherLocationAsPathAbsolute]
-  | [
-      relativeLocation: MatcherLocationAsPathRelative,
-      currentLocation: NEW_LocationResolved
-    ]
-  | [location: MatcherLocationAsNamed]
-  | [
-      relativeLocation: MatcherLocationAsRelative,
-      currentLocation: NEW_LocationResolved
-    ]
-
 /**
- * Allowed location objects to be passed to {@link RouteResolver['resolve']}
+ * Allowed location objects to be passed to {@link NEW_RouterMatcher['resolve']}
  */
 export type MatcherLocationRaw =
   | `/${string}`
@@ -127,16 +122,18 @@ export interface NEW_Matcher_Dynamic {
 
 type TODO = any
 
-export interface NEW_LocationResolved {
-  name: MatcherName
-  fullPath: string
-  path: string
+export interface NEW_LocationResolved<TMatched> {
+  // FIXME: remove `undefined`
+  name: MatcherName | undefined
   // TODO: generics?
   params: MatcherParamsFormatted
+
+  fullPath: string
+  path: string
   query: LocationQuery
   hash: string
 
-  matched: TODO[]
+  matched: TMatched[]
 }
 
 export type MatcherPathParamsValue = string | null | string[]
@@ -221,24 +218,69 @@ const encodeQueryValue: FnStableNull =
 //   // for ts
 //   value => (value == null ? null : _encodeQueryKey(value))
 
+/**
+ * Common properties for a location that couldn't be matched. This ensures
+ * having the same name while having a `path`, `query` and `hash` that change.
+ */
 export const NO_MATCH_LOCATION = {
   name: __DEV__ ? Symbol('no-match') : Symbol(),
   params: {},
   matched: [],
-} satisfies Omit<NEW_LocationResolved, 'path' | 'hash' | 'query' | 'fullPath'>
+} satisfies Omit<
+  NEW_LocationResolved<unknown>,
+  'path' | 'hash' | 'query' | 'fullPath'
+>
 
 // FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc)
 
-export interface MatcherRecordRaw {
+/**
+ * Experiment 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?: MatcherName
 
-  path: MatcherPatternPath
+  /**
+   * Array of nested routes.
+   */
+  children?: NEW_MatcherRecordRaw[]
+}
 
-  query?: MatcherPatternQuery
+/**
+ * Normalized version of a {@link NEW_MatcherRecordRaw} record.
+ */
+export interface NEW_MatcherRecord {
+  /**
+   * Name of the matcher. Unique across all matchers.
+   */
+  name: MatcherName
 
+  path: MatcherPatternPath
+  query?: MatcherPatternQuery
   hash?: MatcherPatternHash
 
-  children?: MatcherRecordRaw[]
+  parent?: NEW_MatcherRecord
 }
 
 /**
@@ -268,9 +310,9 @@ export function pathEncoded(
 /**
  * Build the `matched` array of a record that includes all parent records from the root to the current one.
  */
-function buildMatched(record: MatcherPattern): MatcherPattern[] {
-  const matched: MatcherPattern[] = []
-  let node: MatcherPattern | undefined = record
+function buildMatched(record: NEW_MatcherRecord): NEW_MatcherRecord[] {
+  const matched: NEW_MatcherRecord[] = []
+  let node: NEW_MatcherRecord | undefined = record
   while (node) {
     matched.unshift(node)
     node = node.parent
@@ -279,10 +321,10 @@ function buildMatched(record: MatcherPattern): MatcherPattern[] {
 }
 
 export function createCompiledMatcher(
-  records: MatcherRecordRaw[] = []
-): RouteResolver<MatcherRecordRaw, MatcherPattern> {
+  records: NEW_MatcherRecordRaw[] = []
+): NEW_RouterMatcher<NEW_MatcherRecordRaw, NEW_MatcherRecord> {
   // TODO: we also need an array that has the correct order
-  const matchers = new Map<MatcherName, MatcherPattern>()
+  const matchers = new Map<MatcherName, NEW_MatcherRecord>()
 
   // TODO: allow custom encode/decode functions
   // const encodeParams = applyToParams.bind(null, encodeParam)
@@ -294,7 +336,30 @@ export function createCompiledMatcher(
   // )
   // const decodeQuery = transformObject.bind(null, decode, decode)
 
-  function resolve(...args: MatcherResolveArgs): NEW_LocationResolved {
+  // NOTE: because of the overloads, we need to manually type the arguments
+  type MatcherResolveArgs =
+    | [
+        absoluteLocation: `/${string}`,
+        currentLocation?: undefined | NEW_LocationResolved<NEW_MatcherRecord>
+      ]
+    | [
+        relativeLocation: string,
+        currentLocation: NEW_LocationResolved<NEW_MatcherRecord>
+      ]
+    | [absoluteLocation: MatcherLocationAsPathAbsolute]
+    | [
+        relativeLocation: MatcherLocationAsPathRelative,
+        currentLocation: NEW_LocationResolved<NEW_MatcherRecord>
+      ]
+    | [location: MatcherLocationAsNamed]
+    | [
+        relativeLocation: MatcherLocationAsRelative,
+        currentLocation: NEW_LocationResolved<NEW_MatcherRecord>
+      ]
+
+  function resolve(
+    ...args: MatcherResolveArgs
+  ): NEW_LocationResolved<NEW_MatcherRecord> {
     const [location, currentLocation] = args
 
     // string location, e.g. '/foo', '../bar', 'baz', '?page=1'
@@ -302,8 +367,10 @@ export function createCompiledMatcher(
       // parseURL handles relative paths
       const url = parseURL(parseQuery, location, currentLocation?.path)
 
-      let matcher: MatcherPattern | undefined
-      let matched: NEW_LocationResolved['matched'] | undefined
+      let matcher: NEW_MatcherRecord | undefined
+      let matched:
+        | NEW_LocationResolved<NEW_MatcherRecord>['matched']
+        | undefined
       let parsedParams: MatcherParamsFormatted | null | undefined
 
       for (matcher of matchers.values()) {
@@ -360,18 +427,22 @@ export function createCompiledMatcher(
           `Cannot resolve an unnamed relative location without a current location. This will throw in production.`,
           location
         )
+        const query = normalizeQuery(location.query)
+        const hash = location.hash ?? ''
+        const path = location.path ?? '/'
         return {
           ...NO_MATCH_LOCATION,
-          fullPath: '/',
-          path: '/',
-          query: {},
-          hash: '',
+          fullPath: 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 = location.name ?? currentLocation!.name
-      const matcher = matchers.get(name)
+      // FIXME: remove once name cannot be null
+      const matcher = name != null && matchers.get(name)
       if (!matcher) {
         throw new Error(`Matcher "${String(location.name)}" not found`)
       }
@@ -404,10 +475,10 @@ export function createCompiledMatcher(
     }
   }
 
-  function addRoute(record: MatcherRecordRaw, parent?: MatcherPattern) {
+  function addRoute(record: NEW_MatcherRecordRaw, parent?: NEW_MatcherRecord) {
     const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol())
     // FIXME: proper normalization of the record
-    const normalizedRecord: MatcherPattern = {
+    const normalizedRecord: NEW_MatcherRecord = {
       ...record,
       name,
       parent,
@@ -420,7 +491,7 @@ export function createCompiledMatcher(
     addRoute(record)
   }
 
-  function removeRoute(matcher: MatcherPattern) {
+  function removeRoute(matcher: NEW_MatcherRecord) {
     matchers.delete(matcher.name)
     // TODO: delete children and aliases
   }
index f40ce00a5ae70349aa8fc2b13ed67acac4ea41bb..e922e721731b527785e6c6a9f35693f021c34378 100644 (file)
@@ -3,8 +3,8 @@ import {
   MatcherPatternPath,
   MatcherPatternQuery,
   MatcherPatternParams_Base,
-  MatcherPattern,
 } from '../matcher-pattern'
+import { NEW_MatcherRecord } from '../matcher'
 import { miss } from './errors'
 
 export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{
@@ -68,9 +68,9 @@ export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base<
 export const EMPTY_PATH_ROUTE = {
   name: 'no params',
   path: EMPTY_PATH_PATTERN_MATCHER,
-} satisfies MatcherPattern
+} satisfies NEW_MatcherRecord
 
 export const USER_ID_ROUTE = {
   name: 'user-id',
   path: USER_ID_PATH_PATTERN_MATCHER,
-} satisfies MatcherPattern
+} satisfies NEW_MatcherRecord
index ba30bd9b635eed703dc645baf064639e98cd31c9..9ecbf3a3c86b050ae614fa8156ac21e562ec6dd3 100644 (file)
@@ -4,6 +4,8 @@ export function isRouteLocation(route: any): route is RouteLocationRaw {
   return typeof route === 'string' || (route && typeof route === 'object')
 }
 
-export function isRouteName(name: any): name is RouteRecordNameGeneric {
+export function isRouteName(
+  name: unknown
+): name is NonNullable<RouteRecordNameGeneric> {
   return typeof name === 'string' || typeof name === 'symbol'
 }