]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: simplify new resolver to be static
authorEduardo San Martin Morote <posva13@gmail.com>
Thu, 17 Jul 2025 20:22:51 +0000 (22:22 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Thu, 17 Jul 2025 20:22:51 +0000 (22:22 +0200)
12 files changed:
packages/router/src/experimental/router.ts
packages/router/src/navigationGuards.ts
packages/router/src/new-route-resolver/matcher-location.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/matchers/errors.ts
packages/router/src/new-route-resolver/matchers/test-utils.ts
packages/router/src/new-route-resolver/resolver-static.ts [new file with mode: 0644]
packages/router/src/new-route-resolver/resolver.spec.ts
packages/router/src/new-route-resolver/resolver.ts
packages/router/src/router.ts
packages/router/test-dts/index.d.ts

index 2347a5f9b27ce4e62f698919409459a281f2ff71..e6a933c7a8b41162b428ad11c5ed10eeb9a90da3 100644 (file)
@@ -16,20 +16,13 @@ import {
   type App,
 } from 'vue'
 import { RouterLink } from '../RouterLink'
-import { RouterView } from '../RouterView'
 import {
   NavigationType,
   type HistoryState,
   type RouterHistory,
 } from '../history/common'
 import type { PathParserOptions } from '../matcher'
-import {
-  type NEW_MatcherRecordBase,
-  type NEW_LocationResolved,
-  type NEW_MatcherRecord,
-  type NEW_MatcherRecordRaw,
-  type NEW_RouterResolver,
-} from '../new-route-resolver/resolver'
+import { type NEW_LocationResolved } from '../new-route-resolver/resolver'
 import {
   parseQuery as originalParseQuery,
   stringifyQuery as originalStringifyQuery,
@@ -45,6 +38,7 @@ import {
   type RouterScrollBehavior,
 } from '../scrollBehavior'
 import type {
+  _RouteRecordProps,
   NavigationGuardWithThis,
   NavigationHookAfter,
   RouteLocation,
@@ -61,8 +55,8 @@ import type {
 } from '../typed-routes'
 import {
   isRouteLocation,
-  isRouteName,
   Lazy,
+  RawRouteComponent,
   RouteLocationOptions,
   RouteMeta,
 } from '../types'
@@ -84,6 +78,10 @@ import {
   routerKey,
   routerViewLocationKey,
 } from '../injectionSymbols'
+import {
+  EXPERIMENTAL_ResolverStatic,
+  EXPERIMENTAL_ResolverStaticRecord,
+} from '../new-route-resolver/resolver-static'
 
 /**
  * resolve, reject arguments of Promise constructor
@@ -179,30 +177,58 @@ export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
   // linkInactiveClass?: string
 }
 
+// TODO: is it worth to have 2 types for the undefined values?
+export interface EXPERIMENTAL_RouteRecordNormalized
+  extends EXPERIMENTAL_ResolverStaticRecord {
+  /**
+   * Arbitrary data attached to the record.
+   */
+  meta: RouteMeta
+
+  // TODO:
+  redirect?: unknown
+
+  /**
+   * Allow passing down params as props to the component rendered by `router-view`.
+   */
+  props: Record<string, _RouteRecordProps>
+
+  /**
+   * {@inheritDoc RouteRecordMultipleViews.components}
+   */
+  components: Record<string, RawRouteComponent>
+
+  /**
+   * Contains the original modules for lazy loaded components.
+   * @internal
+   */
+  mods: Record<string, unknown>
+}
+
 /**
  * Options to initialize an experimental {@link EXPERIMENTAL_Router} instance.
  * @experimental
  */
 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<EXPERIMENTAL_RouteRecordRaw[]>
-
+  // TODO: probably need some generic types
+  // TResolver extends NEW_RouterResolver_Base,
+>extends EXPERIMENTAL_RouterOptions_Base {
   /**
    * Matcher to use to resolve routes.
+   *
    * @experimental
    */
-  resolver: NEW_RouterResolver<NEW_MatcherRecordRaw, TMatcherRecord>
+  resolver: EXPERIMENTAL_ResolverStatic<EXPERIMENTAL_RouteRecordNormalized>
 }
 
 /**
  * Router base instance.
+ *
  * @experimental This version is not stable, it's meant to replace {@link Router} in the future.
  */
-export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
+export interface EXPERIMENTAL_Router_Base<TRecord> {
+  // NOTE: for dynamic routing we need this
+  // <TRouteRecordRaw, TRouteRecord>
   /**
    * Current {@link RouteLocationNormalized}
    */
@@ -213,31 +239,6 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
    */
   listening: boolean
 
-  /**
-   * 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
-   */
-  addRoute(
-    // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
-    parentName: NonNullable<RouteRecordNameGeneric>,
-    route: TRouteRecordRaw
-  ): () => void
-  /**
-   * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} to the router.
-   *
-   * @param route - Route Record to add
-   */
-  addRoute(route: TRouteRecordRaw): () => void
-
-  /**
-   * Remove an existing route by its name.
-   *
-   * @param name - Name of the route to remove
-   */
-  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void
-
   /**
    * Checks if a route with a given name exists
    *
@@ -248,12 +249,7 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   /**
    * Get a full list of all the {@link RouteRecord | route records}.
    */
-  getRoutes(): TRouteRecord[]
-
-  /**
-   * Delete all routes from the router matcher.
-   */
-  clearRoutes(): void
+  getRoutes(): TRecord[]
 
   /**
    * Returns the {@link RouteLocation | normalized version} of a
@@ -392,58 +388,49 @@ export interface EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
   install(app: App): void
 }
 
-export interface EXPERIMENTAL_Router<
-  TRouteRecordRaw, // extends NEW_MatcherRecordRaw,
-  TRouteRecord extends NEW_MatcherRecord
-> extends EXPERIMENTAL_Router_Base<TRouteRecordRaw, TRouteRecord> {
+export interface EXPERIMENTAL_Router
+  // TODO: dynamic routing
+  //   <
+  //   TRouteRecordRaw, // extends NEW_MatcherRecordRaw,
+  //   TRouteRecord extends NEW_MatcherRecord,
+  // >
+  extends EXPERIMENTAL_Router_Base<EXPERIMENTAL_RouteRecordNormalized> {
   /**
    * Original options object passed to create the Router
    */
-  readonly options: EXPERIMENTAL_RouterOptions<TRouteRecord>
-}
-
-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
 }
 
-// TODO: is it worth to have 2 types for the undefined values?
-export interface EXPERIMENTAL_RouteRecordNormalized
-  extends NEW_MatcherRecordBase<EXPERIMENTAL_RouteRecordNormalized> {
-  /**
-   * Arbitrary data attached to the record.
-   */
-  meta: RouteMeta
-  group?: boolean
-  score: Array<number[]>
-}
-
-function normalizeRouteRecord(
-  record: EXPERIMENTAL_RouteRecordRaw
-): EXPERIMENTAL_RouteRecordNormalized {
-  // FIXME: implementation
-  return {
-    name: __DEV__ ? Symbol('anonymous route record') : Symbol(),
-    meta: {},
-    ...record,
-    children: (record.children || []).map(normalizeRouteRecord),
-  }
-}
+// export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw {
+//   /**
+//    * Arbitrary data attached to the record.
+//    */
+//   meta?: RouteMeta
+//
+//   components?: Record<string, unknown>
+//   component?: unknown
+//
+//   redirect?: unknown
+//   // TODO: Not needed
+//   score: Array<number[]>
+// }
+//
+//
+// function normalizeRouteRecord(
+//   record: EXPERIMENTAL_RouteRecordRaw
+// ): EXPERIMENTAL_RouteRecordNormalized {
+//   // FIXME: implementation
+//   return {
+//     name: __DEV__ ? Symbol('anonymous route record') : Symbol(),
+//     meta: {},
+//     ...record,
+//     children: (record.children || []).map(normalizeRouteRecord),
+//   }
+// }
 
 export function experimental_createRouter(
-  options: EXPERIMENTAL_RouterOptions<EXPERIMENTAL_RouteRecordNormalized>
-): EXPERIMENTAL_Router<
-  EXPERIMENTAL_RouteRecordRaw,
-  EXPERIMENTAL_RouteRecordNormalized
-> {
+  options: EXPERIMENTAL_RouterOptions
+): EXPERIMENTAL_Router {
   const {
     resolver,
     parseQuery = originalParseQuery,
@@ -451,6 +438,7 @@ export function experimental_createRouter(
     history: routerHistory,
   } = options
 
+  // FIXME: can be removed, it was for migration purposes
   if (__DEV__ && !routerHistory)
     throw new Error(
       'Provide the "history" option when calling "createRouter()":' +
@@ -466,59 +454,16 @@ export function experimental_createRouter(
   let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED
 
   // leave the scrollRestoration if no scrollBehavior is provided
-  if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
+  if (isBrowser && options.scrollBehavior) {
     history.scrollRestoration = 'manual'
   }
 
-  function addRoute(
-    parentOrRoute:
-      | NonNullable<RouteRecordNameGeneric>
-      | EXPERIMENTAL_RouteRecordRaw,
-    route?: EXPERIMENTAL_RouteRecordRaw
-  ) {
-    let parent: Parameters<(typeof resolver)['addMatcher']>[1] | undefined
-    let rawRecord: EXPERIMENTAL_RouteRecordRaw
-
-    if (isRouteName(parentOrRoute)) {
-      parent = resolver.getMatcher(parentOrRoute)
-      if (__DEV__ && !parent) {
-        warn(
-          `Parent route "${String(
-            parentOrRoute
-          )}" not found when adding child route`,
-          route
-        )
-      }
-      rawRecord = route!
-    } else {
-      rawRecord = parentOrRoute
-    }
-
-    const addedRecord = resolver.addMatcher(
-      normalizeRouteRecord(rawRecord),
-      parent
-    )
-
-    return () => {
-      resolver.removeMatcher(addedRecord)
-    }
-  }
-
-  function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
-    const recordMatcher = resolver.getMatcher(name)
-    if (recordMatcher) {
-      resolver.removeMatcher(recordMatcher)
-    } else if (__DEV__) {
-      warn(`Cannot remove non-existent route "${String(name)}"`)
-    }
-  }
-
   function getRoutes() {
-    return resolver.getMatchers()
+    return resolver.getRecords()
   }
 
   function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
-    return !!resolver.getMatcher(name)
+    return !!resolver.getRecord(name)
   }
 
   function locationAsObject(
@@ -812,9 +757,10 @@ export function experimental_createRouter(
 
   function runWithContext<T>(fn: () => T): T {
     const app: App | undefined = installedApps.values().next().value
+    // FIXME: remove safeguard and ensure
     // TODO: remove safeguard and bump required minimum version of Vue
     // support Vue < 3.3
-    return app && typeof app.runWithContext === 'function'
+    return typeof app?.runWithContext === 'function'
       ? app.runWithContext(fn)
       : fn()
   }
@@ -1223,16 +1169,10 @@ export function experimental_createRouter(
   let started: boolean | undefined
   const installedApps = new Set<App>()
 
-  const router: EXPERIMENTAL_Router<
-    EXPERIMENTAL_RouteRecordRaw,
-    EXPERIMENTAL_RouteRecordNormalized
-  > = {
+  const router: EXPERIMENTAL_Router = {
     currentRoute,
     listening: true,
 
-    addRoute,
-    removeRoute,
-    clearRoutes: resolver.clearMatchers,
     hasRoute,
     getRoutes,
     resolve,
@@ -1252,9 +1192,9 @@ export function experimental_createRouter(
     isReady,
 
     install(app: App) {
-      const router = this
-      app.component('RouterLink', RouterLink)
-      app.component('RouterView', RouterView)
+      // Must be done by user for vapor variants
+      // 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
@@ -1293,9 +1233,8 @@ export function experimental_createRouter(
       app.provide(routeLocationKey, shallowReactive(reactiveRoute))
       app.provide(routerViewLocationKey, currentRoute)
 
-      const unmountApp = app.unmount
       installedApps.add(app)
-      app.unmount = function () {
+      app.onUnmount(() => {
         installedApps.delete(app)
         // the router is not attached to an app anymore
         if (installedApps.size < 1) {
@@ -1307,8 +1246,7 @@ export function experimental_createRouter(
           started = false
           ready = false
         }
-        unmountApp()
-      }
+      })
 
       // TODO: this probably needs to be updated so it can be used by vue-termui
       if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) {
index 0582ce47bef58a29ceba43b843d9a011a41901b2..e0389cd7c6bd0b9216c8617efa4ba9b1061dc24a 100644 (file)
@@ -413,7 +413,7 @@ export function extractChangingRecords(
 ): [
   leavingRecords: RouteRecordNormalized[],
   updatingRecords: RouteRecordNormalized[],
-  enteringRecords: RouteRecordNormalized[]
+  enteringRecords: RouteRecordNormalized[],
 ] {
   const leavingRecords: RouteRecordNormalized[] = []
   const updatingRecords: RouteRecordNormalized[] = []
index e05fdf7b32389875774512040a3a8aa4e5d6cf41..ec1431cf8a807697aa41f81adeca3ad9b1c3a92a 100644 (file)
@@ -1,5 +1,7 @@
 import type { LocationQueryRaw } from '../query'
-import type { MatcherName } from './resolver'
+import type { RecordName } from './resolver'
+
+// FIXME: rename to ResolverLocation... instead of MatcherLocation... since they are returned by a resolver
 
 /**
  * Generic object of params that can be passed to a matcher.
@@ -12,7 +14,7 @@ export type MatcherParamsFormatted = Record<string, unknown>
 export type EmptyParams = Record<PropertyKey, never>
 
 export interface MatcherLocationAsNamed {
-  name: MatcherName
+  name: RecordName
   // FIXME: should this be optional?
   params: MatcherParamsFormatted
   query?: LocationQueryRaw
index 0f7d8c1920df4ed442b403c32a49aae99e078444..e0efb2eea9d7aa719cee072cf1a3af1e8dd8cec1 100644 (file)
@@ -2,16 +2,28 @@ import { decode, MatcherQueryParams } from './resolver'
 import { EmptyParams, MatcherParamsFormatted } from './matcher-location'
 import { miss } from './matchers/errors'
 
-export interface MatcherPatternParams_Base<
+/**
+ * Base interface for matcher patterns that extract params from a URL.
+ *
+ * @template TIn - type of the input value to match against the pattern
+ * @template TOut - type of the output value after matching
+ *
+ * In the case of the `path`, the `TIn` is a `string`, but in the case of the
+ * query, it's the object of query params.
+ *
+ * @internal this is the base interface for all matcher patterns, it shouldn't
+ * be used directly
+ */
+export interface MatcherPattern<
   TIn = string,
-  TOut extends MatcherParamsFormatted = MatcherParamsFormatted
+  TOut extends MatcherParamsFormatted = MatcherParamsFormatted,
 > {
   /**
    * Matches a serialized params value against the pattern.
    *
    * @param value - params value to parse
    * @throws {MatchMiss} if the value doesn't match
-   * @returns parsed params
+   * @returns parsed params object
    */
   match(value: TIn): TOut
 
@@ -21,14 +33,19 @@ export interface MatcherPatternParams_Base<
    * shouldn't).
    *
    * @param value - params value to parse
+   * @returns serialized params value
    */
   build(params: TOut): TIn
 }
 
+/**
+ * Handles the `path` part of a URL. It can transform a path string into an
+ * object of params and vice versa.
+ */
 export interface MatcherPatternPath<
   // TODO: should we allow to not return anything? It's valid to spread null and undefined
-  TParams extends MatcherParamsFormatted = MatcherParamsFormatted // | null // | undefined // | void // so it might be a bit more convenient
-> extends MatcherPatternParams_Base<string, TParams> {}
+  TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient
+> extends MatcherPattern<string, TParams> {}
 
 export class MatcherPatternPathStatic
   implements MatcherPatternPath<EmptyParams>
@@ -48,10 +65,11 @@ export class MatcherPatternPathStatic
 }
 // example of a static matcher built at runtime
 // new MatcherPatternPathStatic('/')
+// new MatcherPatternPathStatic('/team')
 
 export interface Param_GetSet<
   TIn extends string | string[] = string | string[],
-  TOut = TIn
+  TOut = TIn,
 > {
   get?: (value: NoInfer<TIn>) => TOut
   set?: (value: NoInfer<TOut>) => TIn
@@ -115,10 +133,11 @@ export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
 }
 
 export class MatcherPatternPathDynamic<
-  TParams extends MatcherParamsFormatted = MatcherParamsFormatted
+  TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
 > implements MatcherPatternPath<TParams>
 {
   private params: Record<string, Required<ParamParser_Generic>> = {}
+
   constructor(
     private re: RegExp,
     params: Record<keyof TParams, ParamParser_Generic>,
@@ -186,10 +205,18 @@ export class MatcherPatternPathDynamic<
   // }
 }
 
+/**
+ * Handles the `query` part of a URL. It can transform a query object into an
+ * object of params and vice versa.
+ */
 export interface MatcherPatternQuery<
-  TParams extends MatcherParamsFormatted = MatcherParamsFormatted
-> extends MatcherPatternParams_Base<MatcherQueryParams, TParams> {}
+  TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
+> extends MatcherPattern<MatcherQueryParams, TParams> {}
 
+/**
+ * Handles the `hash` part of a URL. It can transform a hash string into an
+ * object of params and vice versa.
+ */
 export interface MatcherPatternHash<
-  TParams extends MatcherParamsFormatted = MatcherParamsFormatted
-> extends MatcherPatternParams_Base<string, TParams> {}
+  TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
+> extends MatcherPattern<string, TParams> {}
index 77b37489dbd3258e08f67b22d5918418e952356c..af02741eb124b8bc6d1339aa16caf2de6d8c8427 100644 (file)
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { defineComponent } from 'vue'
-import { RouteComponent, RouteRecordRaw } from '../types'
+import { RouteComponent, RouteMeta, RouteRecordRaw } from '../types'
 import { NEW_stringifyURL } from '../location'
 import { mockWarn } from '../../__tests__/vitest-mock-warn'
 import {
@@ -13,7 +13,7 @@ import {
 } from './resolver'
 import { miss } from './matchers/errors'
 import { MatcherPatternPath, MatcherPatternPathStatic } from './matcher-pattern'
-import { type EXPERIMENTAL_RouteRecordRaw } from '../experimental/router'
+import { EXPERIMENTAL_RouterOptions } from '../experimental/router'
 import { stringifyQuery } from '../query'
 import type {
   MatcherLocationAsNamed,
@@ -29,6 +29,21 @@ import {
 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
@@ -147,7 +162,7 @@ describe('RouterMatcher.resolve', () => {
       | MatcherLocationAsPathAbsolute = START_LOCATION
   ) {
     const records = (Array.isArray(record) ? record : [record]).map(
-      (record): EXPERIMENTAL_RouteRecordRaw =>
+      (record): NEW_MatcherRecordRaw =>
         isExperimentalRouteRecordRaw(record)
           ? { components, ...record }
           : compileRouteRecord(record)
index 4ad69cc4ca11e68f94fd6cf2cb5d1229c30ca68c..142b37ff8fffdf31d0254a3adf47aed394c4196c 100644 (file)
@@ -2,6 +2,8 @@
  * NOTE: for these classes to keep the same code we need to tell TS with `"useDefineForClassFields": true` in the `tsconfig.json`
  */
 
+// TODO: document helpers if kept. The helpers could also be moved to the generated code to reduce bundle size. After all, user is unlikely to write these manually
+
 /**
  * Error throw when a matcher miss
  */
@@ -11,6 +13,9 @@ export class MatchMiss extends Error {
 
 // NOTE: not sure about having a helper. Using `new MatchMiss(description?)` is good enough
 export const miss = () => new MatchMiss()
+// TODO: which one?, the return type of never makes types work anyway
+// export const throwMiss = () => { throw new MatchMiss() }
+// export const throwMiss = (...args: ConstructorParameters<typeof MatchMiss>) => { throw new MatchMiss(...args) }
 
 /**
  * Error throw when a param is invalid when parsing params from path, query, or hash.
index 4c72d8331a99c6c4d77c2ba27e3118db338f850a..b48b9362ea8ef088aba5fa5ada7e5f1363277c3f 100644 (file)
@@ -2,10 +2,10 @@ import { EmptyParams } from '../matcher-location'
 import {
   MatcherPatternPath,
   MatcherPatternQuery,
-  MatcherPatternParams_Base,
+  MatcherPatternHash,
 } from '../matcher-pattern'
 import { NEW_MatcherRecord } from '../resolver'
-import { miss } from './errors'
+import { invalid, miss } from './errors'
 
 export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{
   pathMatch: string
@@ -37,7 +37,8 @@ export const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> =
       }
       const id = Number(match[1])
       if (Number.isNaN(id)) {
-        throw miss()
+        throw invalid('id')
+        // throw miss()
       }
       return { id }
     },
@@ -55,12 +56,10 @@ export const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> =
       }
     },
     build: params => ({ page: String(params.page) }),
-  } satisfies MatcherPatternQuery<{ page: number }>
+  }
 
-export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base<
-  string,
-  { hash: string | null }
-> = {
+export const ANY_HASH_PATTERN_MATCHER: MatcherPatternHash<// hash could be named anything, in this case it creates a param named hash
+{ hash: string | null }> = {
   match: hash => ({ hash: hash ? hash.slice(1) : null }),
   build: ({ hash }) => (hash ? `#${hash}` : ''),
 }
diff --git a/packages/router/src/new-route-resolver/resolver-static.ts b/packages/router/src/new-route-resolver/resolver-static.ts
new file mode 100644 (file)
index 0000000..1558fa8
--- /dev/null
@@ -0,0 +1,198 @@
+import { normalizeQuery, parseQuery, stringifyQuery } from '../query'
+import {
+  LocationNormalized,
+  NEW_stringifyURL,
+  parseURL,
+  resolveRelativePath,
+} from '../location'
+import {
+  MatcherLocationAsNamed,
+  MatcherLocationAsPathAbsolute,
+  MatcherLocationAsPathRelative,
+  MatcherLocationAsRelative,
+  MatcherParamsFormatted,
+} from './matcher-location'
+import {
+  buildMatched,
+  EXPERIMENTAL_ResolverRecord_Base,
+  RecordName,
+  MatcherQueryParams,
+  NEW_LocationResolved,
+  NEW_RouterResolver_Base,
+  NO_MATCH_LOCATION,
+} from './resolver'
+
+export interface EXPERIMENTAL_ResolverStaticRecord
+  extends EXPERIMENTAL_ResolverRecord_Base {}
+
+export interface EXPERIMENTAL_ResolverStatic<TRecord>
+  extends NEW_RouterResolver_Base<TRecord> {}
+
+export function createStaticResolver<
+  TRecord extends EXPERIMENTAL_ResolverStaticRecord,
+>(records: TRecord[]): EXPERIMENTAL_ResolverStatic<TRecord> {
+  // allows fast access to a matcher by name
+  const recordMap = new Map<RecordName, TRecord>()
+  for (const record of records) {
+    recordMap.set(record.name, record)
+  }
+
+  // NOTE: because of the overloads, we need to manually type the arguments
+  type _resolveArgs =
+    | [absoluteLocation: `/${string}`, currentLocation?: undefined]
+    | [relativeLocation: string, currentLocation: NEW_LocationResolved<TRecord>]
+    | [
+        absoluteLocation: MatcherLocationAsPathAbsolute,
+        // Same as above
+        // currentLocation?: NEW_LocationResolved<TRecord> | undefined
+        currentLocation?: undefined,
+      ]
+    | [
+        relativeLocation: MatcherLocationAsPathRelative,
+        currentLocation: NEW_LocationResolved<TRecord>,
+      ]
+    | [
+        location: MatcherLocationAsNamed,
+        // Same as above
+        // currentLocation?: NEW_LocationResolved<TRecord> | undefined
+        currentLocation?: undefined,
+      ]
+    | [
+        relativeLocation: MatcherLocationAsRelative,
+        currentLocation: NEW_LocationResolved<TRecord>,
+      ]
+
+  function resolve(
+    ...[to, currentLocation]: _resolveArgs
+  ): NEW_LocationResolved<TRecord> {
+    if (typeof to === 'object' && (to.name || to.path == null)) {
+      // relative location by path or by name
+      if (__DEV__ && to.name == null && currentLocation == null) {
+        console.warn(
+          `Cannot resolve relative location "${JSON.stringify(to)}"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
+      const record = recordMap.get(name)!
+      if (__DEV__ && (!record || !name)) {
+        throw new Error(`Record "${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 = record.path.build(params)
+      const hash = record.hash?.build(params) ?? ''
+      const matched = buildMatched(record)
+      const query = Object.assign(
+        {
+          ...currentLocation?.query,
+          ...normalizeQuery(to.query),
+        },
+        ...matched.map(record => record.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 record: TRecord | undefined
+      let matched: NEW_LocationResolved<TRecord>['matched'] | undefined
+      let parsedParams: MatcherParamsFormatted | null | undefined
+
+      for (record of records) {
+        // 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 = record.path.match(url.path)
+          const hashParams = record.hash?.match(url.hash)
+          matched = buildMatched(record)
+          const queryParams: MatcherQueryParams = Object.assign(
+            {},
+            ...matched.map(record => record.query?.match(url.query))
+          )
+          // TODO: test performance
+          // for (const record of matched) {
+          //   Object.assign(queryParams, record.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,
+        // record exists if matched exists
+        name: record!.name,
+        params: parsedParams,
+        matched,
+      }
+      // TODO: handle object location { path, query, hash }
+    }
+  }
+
+  return {
+    resolve,
+    getRecords: () => records,
+    getRecord: name => recordMap.get(name),
+  }
+}
index da7b388e7a8377713c7ed2fc01151d16ddab30ca..93a269c96d35c669ea22679a520df37d5138ed9e 100644 (file)
@@ -5,56 +5,20 @@ import {
   pathEncoded,
 } from './resolver'
 import {
-  MatcherPatternParams_Base,
-  MatcherPatternPath,
   MatcherPatternQuery,
   MatcherPatternPathStatic,
   MatcherPatternPathDynamic,
 } from './matcher-pattern'
-import { miss } from './matchers/errors'
-import { EmptyParams } from './matcher-location'
 import {
   EMPTY_PATH_ROUTE,
   USER_ID_ROUTE,
   ANY_PATH_ROUTE,
+  ANY_PATH_PATTERN_MATCHER,
+  EMPTY_PATH_PATTERN_MATCHER,
+  USER_ID_PATH_PATTERN_MATCHER,
+  ANY_HASH_PATTERN_MATCHER,
 } from './matchers/test-utils'
 
-const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = {
-  match(path) {
-    return { pathMatch: path }
-  },
-  build({ pathMatch }) {
-    return pathMatch
-  },
-}
-
-const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath<EmptyParams> = {
-  match: path => {
-    if (path !== '/') {
-      throw miss()
-    }
-    return {}
-  },
-  build: () => '/',
-}
-
-const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> = {
-  match(value) {
-    const match = value.match(/^\/users\/(\d+)$/)
-    if (!match?.[1]) {
-      throw miss()
-    }
-    const id = Number(match[1])
-    if (Number.isNaN(id)) {
-      throw miss()
-    }
-    return { id }
-  },
-  build({ id }) {
-    return `/users/${id}`
-  },
-}
-
 const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = {
   match: query => {
     const page = Number(query.page)
@@ -65,14 +29,6 @@ const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = {
   build: params => ({ page: String(params.page) }),
 } satisfies MatcherPatternQuery<{ page: number }>
 
-const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base<
-  string,
-  { hash: string | null }
-> = {
-  match: hash => ({ hash: hash ? hash.slice(1) : null }),
-  build: ({ hash }) => (hash ? `#${hash}` : ''),
-}
-
 describe('RouterMatcher', () => {
   describe('new matchers', () => {
     it('static path', () => {
index e9d198b0daca73d514bdb9cc2e37fd70c1a09ff5..fde6f38f7cc2892bd0667a6277ff63b24ca8f187 100644 (file)
@@ -5,6 +5,7 @@ import {
   stringifyQuery,
 } from '../query'
 import type {
+  MatcherPattern,
   MatcherPatternHash,
   MatcherPatternPath,
   MatcherPatternQuery,
@@ -30,23 +31,26 @@ import { comparePathParserScore } from '../matcher/pathParserRanker'
 /**
  * Allowed types for a matcher name.
  */
-export type MatcherName = string | symbol
+export type RecordName = 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 addMatcher}.
- * - `TMatcherRecord` represents the normalized record type returned by {@link getMatchers}.
+ * - `TMatcherRecord` represents the normalized record type returned by {@link getRecords}.
  */
-export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
+export interface NEW_RouterResolver_Base<TRecord> {
   /**
    * Resolves an absolute location (like `/path/to/somewhere`).
+   *
+   * @param absoluteLocation - The absolute location to resolve.
+   * @param currentLocation - This value is ignored and should not be passed if the location is absolute.
    */
   resolve(
     absoluteLocation: `/${string}`,
     currentLocation?: undefined
-  ): NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TRecord>
 
   /**
    * Resolves a string location relative to another location. A relative location can be `./same-folder`,
@@ -54,8 +58,8 @@ export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
    */
   resolve(
     relativeLocation: string,
-    currentLocation: NEW_LocationResolved<TMatcherRecord>
-  ): NEW_LocationResolved<TMatcherRecord>
+    currentLocation: NEW_LocationResolved<TRecord>
+  ): NEW_LocationResolved<TRecord>
 
   /**
    * Resolves a location by its name. Any required params or query must be passed in the `options` argument.
@@ -65,7 +69,7 @@ export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
     // TODO: is this useful?
     currentLocation?: undefined
     // currentLocation?: undefined | NEW_LocationResolved<TMatcherRecord>
-  ): NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TRecord>
 
   /**
    * Resolves a location by its absolute path (starts with `/`). Any required query must be passed.
@@ -76,12 +80,12 @@ export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
     // TODO: is this useful?
     currentLocation?: undefined
     // currentLocation?: NEW_LocationResolved<TMatcherRecord> | undefined
-  ): NEW_LocationResolved<TMatcherRecord>
+  ): NEW_LocationResolved<TRecord>
 
   resolve(
     location: MatcherLocationAsPathRelative,
-    currentLocation: NEW_LocationResolved<TMatcherRecord>
-  ): NEW_LocationResolved<TMatcherRecord>
+    currentLocation: NEW_LocationResolved<TRecord>
+  ): NEW_LocationResolved<TRecord>
 
   // NOTE: in practice, this overload can cause bugs. It's better to use named locations
 
@@ -91,9 +95,31 @@ export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
    */
   resolve(
     relativeLocation: MatcherLocationAsRelative,
-    currentLocation: NEW_LocationResolved<TMatcherRecord>
-  ): NEW_LocationResolved<TMatcherRecord>
+    currentLocation: NEW_LocationResolved<TRecord>
+  ): NEW_LocationResolved<TRecord>
 
+  /**
+   * Get a list of all resolver records.
+   * Previously named `getRoutes()`
+   */
+  getRecords(): TRecord[]
+
+  /**
+   * Get a resolver record by its name.
+   * Previously named `getRecordMatcher()`
+   */
+  getRecord(name: RecordName): TRecord | undefined
+}
+
+/**
+ * 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 NEW_RouterResolver_Base<TMatcherRecord> {
   /**
    * Add a matcher record. Previously named `addRoute()`.
    * @param matcher - The matcher record to add.
@@ -114,18 +140,6 @@ export interface NEW_RouterResolver<TMatcherRecordRaw, TMatcherRecord> {
    * Remove all matcher records. Prevoisly named `clearRoutes()`.
    */
   clearMatchers(): void
-
-  /**
-   * Get a list of all matchers.
-   * Previously named `getRoutes()`
-   */
-  getMatchers(): TMatcherRecord[]
-
-  /**
-   * Get a matcher by its name.
-   * Previously named `getRecordMatcher()`
-   */
-  getMatcher(name: MatcherName): TMatcherRecord | undefined
 }
 
 /**
@@ -139,10 +153,9 @@ export type MatcherLocationRaw =
   | MatcherLocationAsPathRelative
   | MatcherLocationAsRelative
 
+// TODO: ResolverLocationResolved
 export interface NEW_LocationResolved<TMatched> {
-  // FIXME: remove `undefined`
-  name: MatcherName | undefined
-  // TODO: generics?
+  name: RecordName
   params: MatcherParamsFormatted
 
   fullPath: string
@@ -159,6 +172,7 @@ export type MatcherPathParamsValue = string | null | string[]
  */
 export type MatcherPathParams = Record<string, MatcherPathParamsValue>
 
+// TODO: move to matcher-pattern
 export type MatcherQueryParamsValue = string | null | Array<string | null>
 export type MatcherQueryParams = Record<string, MatcherQueryParamsValue>
 
@@ -276,7 +290,7 @@ export interface NEW_MatcherRecordRaw {
    * Name for the route record. Must be unique. Will be set to `Symbol()` if
    * not set.
    */
-  name?: MatcherName
+  name?: RecordName
 
   /**
    * Array of nested routes.
@@ -291,29 +305,50 @@ export interface NEW_MatcherRecordRaw {
   score: Array<number[]>
 }
 
-export interface NEW_MatcherRecordBase<T> {
+export interface EXPERIMENTAL_ResolverRecord_Base {
   /**
    * Name of the matcher. Unique across all matchers.
    */
-  name: MatcherName
+  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
 
-  parent?: T
-  children: T[]
+  // TODO: here or in router
+  // redirect?: RouteRecordRedirectOption
 
+  parent?: this
+  children: this[]
+  aliasOf?: this
+
+  /**
+   * Is this a record that groups children. Cannot be matched
+   */
   group?: boolean
-  aliasOf?: NEW_MatcherRecord
+}
+
+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[]>
 }
 
 /**
  * Normalized version of a {@link NEW_MatcherRecordRaw} record.
  */
-export interface NEW_MatcherRecord
-  extends NEW_MatcherRecordBase<NEW_MatcherRecord> {}
+export interface NEW_MatcherRecord extends NEW_MatcherDynamicRecord {}
 
 /**
  * Tagged template helper to encode params into a path. Doesn't work with null
@@ -342,7 +377,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<T extends NEW_MatcherRecordBase<T>>(record: T): T[] {
+export function buildMatched<T extends EXPERIMENTAL_ResolverRecord_Base>(
+  record: T
+): T[] {
   const matched: T[] = []
   let node: T | undefined = record
   while (node) {
@@ -353,12 +390,12 @@ function buildMatched<T extends NEW_MatcherRecordBase<T>>(record: T): T[] {
 }
 
 export function createCompiledMatcher<
-  TMatcherRecord extends NEW_MatcherRecordBase<TMatcherRecord>
+  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<MatcherName, TMatcherRecord>()
+  const matcherMap = new Map<RecordName, TMatcherRecord>()
   const matchers: TMatcherRecord[] = []
 
   // TODO: allow custom encode/decode functions
@@ -376,27 +413,27 @@ export function createCompiledMatcher<
     | [absoluteLocation: `/${string}`, currentLocation?: undefined]
     | [
         relativeLocation: string,
-        currentLocation: NEW_LocationResolved<TMatcherRecord>
+        currentLocation: NEW_LocationResolved<TMatcherRecord>,
       ]
     | [
         absoluteLocation: MatcherLocationAsPathAbsolute,
         // Same as above
         // currentLocation?: NEW_LocationResolved<TMatcherRecord> | undefined
-        currentLocation?: undefined
+        currentLocation?: undefined,
       ]
     | [
         relativeLocation: MatcherLocationAsPathRelative,
-        currentLocation: NEW_LocationResolved<TMatcherRecord>
+        currentLocation: NEW_LocationResolved<TMatcherRecord>,
       ]
     | [
         location: MatcherLocationAsNamed,
         // Same as above
         // currentLocation?: NEW_LocationResolved<TMatcherRecord> | undefined
-        currentLocation?: undefined
+        currentLocation?: undefined,
       ]
     | [
         relativeLocation: MatcherLocationAsRelative,
-        currentLocation: NEW_LocationResolved<TMatcherRecord>
+        currentLocation: NEW_LocationResolved<TMatcherRecord>,
       ]
 
   function resolve(
@@ -576,11 +613,11 @@ export function createCompiledMatcher<
     matcherMap.clear()
   }
 
-  function getMatchers() {
+  function getRecords() {
     return matchers
   }
 
-  function getMatcher(name: MatcherName) {
+  function getRecord(name: RecordName) {
     return matcherMap.get(name)
   }
 
@@ -590,8 +627,8 @@ export function createCompiledMatcher<
     addMatcher,
     removeMatcher,
     clearMatchers,
-    getMatcher,
-    getMatchers,
+    getRecord,
+    getRecords,
   }
 }
 
@@ -604,7 +641,7 @@ export function createCompiledMatcher<
  * @param matcher - new matcher to be inserted
  * @param matchers - existing matchers
  */
-function findInsertionIndex<T extends NEW_MatcherRecordBase<T>>(
+function findInsertionIndex<T extends NEW_MatcherDynamicRecord>(
   matcher: T,
   matchers: T[]
 ) {
@@ -641,7 +678,7 @@ function findInsertionIndex<T extends NEW_MatcherRecordBase<T>>(
   return upper
 }
 
-function getInsertionAncestor<T extends NEW_MatcherRecordBase<T>>(matcher: T) {
+function getInsertionAncestor<T extends NEW_MatcherDynamicRecord>(matcher: T) {
   let ancestor: T | undefined = matcher
 
   while ((ancestor = ancestor.parent)) {
@@ -657,7 +694,7 @@ function getInsertionAncestor<T extends NEW_MatcherRecordBase<T>>(matcher: T) {
  * Checks if a record or any of its parent is an alias
  * @param record
  */
-function isAliasRecord<T extends NEW_MatcherRecordBase<T>>(
+function isAliasRecord<T extends NEW_MatcherDynamicRecord>(
   record: T | undefined
 ): boolean {
   while (record) {
index 5746d396cc6ce61b3e0f5eb92801734f09e5596d..01884ea2ab343135916d65c6ea0daa0c8f794816 100644 (file)
@@ -87,11 +87,41 @@ export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base {
  * Router instance.
  */
 export interface Router
-  extends EXPERIMENTAL_Router_Base<RouteRecordRaw, RouteRecordNormalized> {
+  extends EXPERIMENTAL_Router_Base<RouteRecordNormalized> {
   /**
    * Original options object passed to create the Router
    */
   readonly options: RouterOptions
+
+  /**
+   * 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
+   */
+  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
+  ): () => void
+  /**
+   * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} to the router.
+   *
+   * @param route - Route Record to add
+   */
+  addRoute(route: RouteRecordRaw): () => void
+
+  /**
+   * Remove an existing route by its name.
+   *
+   * @param name - Name of the route to remove
+   */
+  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void
+
+  /**
+   * Delete all routes from the router.
+   */
+  clearRoutes(): void
 }
 
 /**
index e7c8be7b6b9ba3aa5d7c3e03c93bad88d0b195dd..62b091993232b4855d448ae4423b60b422e06eaf 100644 (file)
@@ -1,2 +1,2 @@
-export * from '../dist/vue-router'
-// export * from '../src'
+// export * from '../dist/vue-router'
+export * from '../src'