From 4b8ac59c21ea0f70d30bdac399a954a3021efd06 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 17 Jul 2025 22:22:51 +0200 Subject: [PATCH] refactor: simplify new resolver to be static --- packages/router/src/experimental/router.ts | 248 +++++++----------- packages/router/src/navigationGuards.ts | 2 +- .../new-route-resolver/matcher-location.ts | 6 +- .../src/new-route-resolver/matcher-pattern.ts | 49 +++- .../matcher-resolve.spec.ts | 21 +- .../src/new-route-resolver/matchers/errors.ts | 5 + .../new-route-resolver/matchers/test-utils.ts | 15 +- .../src/new-route-resolver/resolver-static.ts | 198 ++++++++++++++ .../src/new-route-resolver/resolver.spec.ts | 52 +--- .../router/src/new-route-resolver/resolver.ts | 137 ++++++---- packages/router/src/router.ts | 32 ++- packages/router/test-dts/index.d.ts | 4 +- 12 files changed, 488 insertions(+), 281 deletions(-) create mode 100644 packages/router/src/new-route-resolver/resolver-static.ts diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 2347a5f9..e6a933c7 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -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 + + /** + * {@inheritDoc RouteRecordMultipleViews.components} + */ + components: Record + + /** + * Contains the original modules for lazy loaded components. + * @internal + */ + mods: Record +} + /** * 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 - + // 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 + resolver: EXPERIMENTAL_ResolverStatic } /** * Router base instance. + * * @experimental This version is not stable, it's meant to replace {@link Router} in the future. */ -export interface EXPERIMENTAL_Router_Base { +export interface EXPERIMENTAL_Router_Base { + // NOTE: for dynamic routing we need this + // /** * Current {@link RouteLocationNormalized} */ @@ -213,31 +239,6 @@ export interface EXPERIMENTAL_Router_Base { */ 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, - 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): void - /** * Checks if a route with a given name exists * @@ -248,12 +249,7 @@ export interface EXPERIMENTAL_Router_Base { /** * 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 { install(app: App): void } -export interface EXPERIMENTAL_Router< - TRouteRecordRaw, // extends NEW_MatcherRecordRaw, - TRouteRecord extends NEW_MatcherRecord -> extends EXPERIMENTAL_Router_Base { +export interface EXPERIMENTAL_Router + // TODO: dynamic routing + // < + // TRouteRecordRaw, // extends NEW_MatcherRecordRaw, + // TRouteRecord extends NEW_MatcherRecord, + // > + extends EXPERIMENTAL_Router_Base { /** * Original options object passed to create the Router */ - readonly options: EXPERIMENTAL_RouterOptions -} - -export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { - /** - * Arbitrary data attached to the record. - */ - meta?: RouteMeta - - components?: Record - component?: unknown - - redirect?: unknown - score: Array + readonly options: EXPERIMENTAL_RouterOptions } -// TODO: is it worth to have 2 types for the undefined values? -export interface EXPERIMENTAL_RouteRecordNormalized - extends NEW_MatcherRecordBase { - /** - * Arbitrary data attached to the record. - */ - meta: RouteMeta - group?: boolean - score: Array -} - -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 +// component?: unknown +// +// redirect?: unknown +// // TODO: Not needed +// score: Array +// } +// +// +// 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_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 - | 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) { - 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): boolean { - return !!resolver.getMatcher(name) + return !!resolver.getRecord(name) } function locationAsObject( @@ -812,9 +757,10 @@ export function experimental_createRouter( function runWithContext(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() - 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) { diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 0582ce47..e0389cd7 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -413,7 +413,7 @@ export function extractChangingRecords( ): [ leavingRecords: RouteRecordNormalized[], updatingRecords: RouteRecordNormalized[], - enteringRecords: RouteRecordNormalized[] + enteringRecords: RouteRecordNormalized[], ] { const leavingRecords: RouteRecordNormalized[] = [] const updatingRecords: RouteRecordNormalized[] = [] diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index e05fdf7b..ec1431cf 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -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 export type EmptyParams = Record export interface MatcherLocationAsNamed { - name: MatcherName + name: RecordName // FIXME: should this be optional? params: MatcherParamsFormatted query?: LocationQueryRaw diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index 0f7d8c19..e0efb2ee 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -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 {} + TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient +> extends MatcherPattern {} export class MatcherPatternPathStatic implements MatcherPatternPath @@ -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) => TOut set?: (value: NoInfer) => TIn @@ -115,10 +133,11 @@ export type ParamsFromParsers

> = { } export class MatcherPatternPathDynamic< - TParams extends MatcherParamsFormatted = MatcherParamsFormatted + TParams extends MatcherParamsFormatted = MatcherParamsFormatted, > implements MatcherPatternPath { private params: Record> = {} + constructor( private re: RegExp, params: Record, @@ -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 {} + TParams extends MatcherParamsFormatted = MatcherParamsFormatted, +> extends MatcherPattern {} +/** + * 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 {} + TParams extends MatcherParamsFormatted = MatcherParamsFormatted, +> extends MatcherPattern {} diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 77b37489..af02741e 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -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 + component?: unknown + + redirect?: unknown + score: Array + 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) diff --git a/packages/router/src/new-route-resolver/matchers/errors.ts b/packages/router/src/new-route-resolver/matchers/errors.ts index 4ad69cc4..142b37ff 100644 --- a/packages/router/src/new-route-resolver/matchers/errors.ts +++ b/packages/router/src/new-route-resolver/matchers/errors.ts @@ -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) => { throw new MatchMiss(...args) } /** * Error throw when a param is invalid when parsing params from path, query, or hash. diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts index 4c72d833..b48b9362 100644 --- a/packages/router/src/new-route-resolver/matchers/test-utils.ts +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -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 = { 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 index 00000000..1558fa8c --- /dev/null +++ b/packages/router/src/new-route-resolver/resolver-static.ts @@ -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 + extends NEW_RouterResolver_Base {} + +export function createStaticResolver< + TRecord extends EXPERIMENTAL_ResolverStaticRecord, +>(records: TRecord[]): EXPERIMENTAL_ResolverStatic { + // allows fast access to a matcher by name + const recordMap = new Map() + 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] + | [ + absoluteLocation: MatcherLocationAsPathAbsolute, + // Same as above + // currentLocation?: NEW_LocationResolved | undefined + currentLocation?: undefined, + ] + | [ + relativeLocation: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved, + ] + | [ + location: MatcherLocationAsNamed, + // Same as above + // currentLocation?: NEW_LocationResolved | undefined + currentLocation?: undefined, + ] + | [ + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_LocationResolved, + ] + + function resolve( + ...[to, currentLocation]: _resolveArgs + ): NEW_LocationResolved { + 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['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), + } +} diff --git a/packages/router/src/new-route-resolver/resolver.spec.ts b/packages/router/src/new-route-resolver/resolver.spec.ts index da7b388e..93a269c9 100644 --- a/packages/router/src/new-route-resolver/resolver.spec.ts +++ b/packages/router/src/new-route-resolver/resolver.spec.ts @@ -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 = { - 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', () => { diff --git a/packages/router/src/new-route-resolver/resolver.ts b/packages/router/src/new-route-resolver/resolver.ts index e9d198b0..fde6f38f 100644 --- a/packages/router/src/new-route-resolver/resolver.ts +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -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 { +export interface NEW_RouterResolver_Base { /** * 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 + ): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, @@ -54,8 +58,8 @@ export interface NEW_RouterResolver { */ resolve( relativeLocation: string, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved /** * 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 { // TODO: is this useful? currentLocation?: undefined // currentLocation?: undefined | NEW_LocationResolved - ): NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a location by its absolute path (starts with `/`). Any required query must be passed. @@ -76,12 +80,12 @@ export interface NEW_RouterResolver { // TODO: is this useful? currentLocation?: undefined // currentLocation?: NEW_LocationResolved | undefined - ): NEW_LocationResolved + ): NEW_LocationResolved resolve( location: MatcherLocationAsPathRelative, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved // NOTE: in practice, this overload can cause bugs. It's better to use named locations @@ -91,9 +95,31 @@ export interface NEW_RouterResolver { */ resolve( relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved + /** + * 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 + extends NEW_RouterResolver_Base { /** * Add a matcher record. Previously named `addRoute()`. * @param matcher - The matcher record to add. @@ -114,18 +140,6 @@ export interface NEW_RouterResolver { * 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 { - // 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 +// TODO: move to matcher-pattern export type MatcherQueryParamsValue = string | null | Array export type MatcherQueryParams = Record @@ -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 } -export interface NEW_MatcherRecordBase { +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 } /** * Normalized version of a {@link NEW_MatcherRecordRaw} record. */ -export interface NEW_MatcherRecord - extends NEW_MatcherRecordBase {} +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>(record: T): T[] { +export function buildMatched( + record: T +): T[] { const matched: T[] = [] let node: T | undefined = record while (node) { @@ -353,12 +390,12 @@ function buildMatched>(record: T): T[] { } export function createCompiledMatcher< - TMatcherRecord extends NEW_MatcherRecordBase + TMatcherRecord extends NEW_MatcherDynamicRecord, >( records: NEW_MatcherRecordRaw[] = [] ): NEW_RouterResolver { // TODO: we also need an array that has the correct order - const matcherMap = new Map() + const matcherMap = new Map() 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 + currentLocation: NEW_LocationResolved, ] | [ absoluteLocation: MatcherLocationAsPathAbsolute, // Same as above // currentLocation?: NEW_LocationResolved | undefined - currentLocation?: undefined + currentLocation?: undefined, ] | [ relativeLocation: MatcherLocationAsPathRelative, - currentLocation: NEW_LocationResolved + currentLocation: NEW_LocationResolved, ] | [ location: MatcherLocationAsNamed, // Same as above // currentLocation?: NEW_LocationResolved | undefined - currentLocation?: undefined + currentLocation?: undefined, ] | [ relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved + currentLocation: NEW_LocationResolved, ] 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>( +function findInsertionIndex( matcher: T, matchers: T[] ) { @@ -641,7 +678,7 @@ function findInsertionIndex>( return upper } -function getInsertionAncestor>(matcher: T) { +function getInsertionAncestor(matcher: T) { let ancestor: T | undefined = matcher while ((ancestor = ancestor.parent)) { @@ -657,7 +694,7 @@ function getInsertionAncestor>(matcher: T) { * Checks if a record or any of its parent is an alias * @param record */ -function isAliasRecord>( +function isAliasRecord( record: T | undefined ): boolean { while (record) { diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 5746d396..01884ea2 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -87,11 +87,41 @@ export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base { * Router instance. */ export interface Router - extends EXPERIMENTAL_Router_Base { + extends EXPERIMENTAL_Router_Base { /** * 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, + 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): void + + /** + * Delete all routes from the router. + */ + clearRoutes(): void } /** diff --git a/packages/router/test-dts/index.d.ts b/packages/router/test-dts/index.d.ts index e7c8be7b..62b09199 100644 --- a/packages/router/test-dts/index.d.ts +++ b/packages/router/test-dts/index.d.ts @@ -1,2 +1,2 @@ -export * from '../dist/vue-router' -// export * from '../src' +// export * from '../dist/vue-router' +export * from '../src' -- 2.47.2