diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index cc73bc9c3..3c79d2c69 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -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 - 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 + routes?: Readonly /** * Matcher to use to resolve routes. * @experimental */ - matcher: RouteResolver + matcher: NEW_RouterMatcher } /** @@ -199,7 +202,7 @@ export interface EXPERIMENTAL_Router_Base { /** * Current {@link RouteLocationNormalized} */ - readonly currentRoute: Ref + readonly currentRoute: ShallowRef /** * 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 { 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 { addRoute( // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build parentName: NonNullable, - 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 { install(app: App): void } -export interface EXPERIMENTAL_Router - extends EXPERIMENTAL_Router_Base { +export interface EXPERIMENTAL_Router< + TRouteRecordRaw, // extends NEW_MatcherRecordRaw, + TRouteRecord extends NEW_MatcherRecord +> extends EXPERIMENTAL_Router_Base { /** * Original options object passed to create the Router */ - readonly options: EXPERIMENTAL_RouterOptions + readonly options: EXPERIMENTAL_RouterOptions +} + +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 { + options: EXPERIMENTAL_RouterOptions +): 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 | RouteRecordRaw, - route?: RouteRecordRaw + parentOrRoute: + | NonNullable + | 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) { @@ -471,7 +506,7 @@ export function experimental_createRouter( } function getRoutes() { - return matcher.getMatchers().map(routeMatcher => routeMatcher.record) + return matcher.getMatchers() } function hasRoute(name: NonNullable): 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 ) + 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 { 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(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() - 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['matched'] +): RouteMeta { + return assign({} as RouteMeta, ...matched.map(r => r.meta)) +} diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index ad582bb8d..c627c3bff 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -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 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 b4799bbec..91fb8fb24 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -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) { diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 22fb3e511..07695b598 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -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-#/?', + }) + }) + }) }) }) diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index a60874518..8ea5b771d 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -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 = {} as any + type TMatcherRecordRaw = NEW_MatcherRecordRaw + type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized + + const matcher: NEW_RouterMatcher = + {} as any describe('matcher.resolve()', () => { it('resolves absolute string locations', () => { - expectTypeOf( - matcher.resolve('/foo') - ).toEqualTypeOf() + expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< + NEW_LocationResolved + >() }) 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() + matcher.resolve('foo', {} as NEW_LocationResolved) + ).toEqualTypeOf>() }) it('resolved named locations', () => { - expectTypeOf( - matcher.resolve({ name: 'foo', params: {} }) - ).toEqualTypeOf() + expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf< + NEW_LocationResolved + >() }) 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() + matcher.resolve( + { params: { id: 1 } }, + {} as NEW_LocationResolved + ) + ).toEqualTypeOf>() }) }) diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 54ea4cba1..69ddc5540 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -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 { +export interface NEW_RouterMatcher { /** * Resolves an absolute location (like `/path/to/somewhere`). */ - resolve(absoluteLocation: `/${string}`): NEW_LocationResolved + resolve( + absoluteLocation: `/${string}`, + currentLocation?: undefined | NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, @@ -41,24 +46,28 @@ export interface RouteResolver { */ 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. */ - resolve(location: MatcherLocationAsNamed): NEW_LocationResolved + resolve( + location: MatcherLocationAsNamed + ): NEW_LocationResolved /** * 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 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 @@ -68,42 +77,28 @@ export interface RouteResolver { */ resolve( relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved - 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 { + // 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 +} satisfies Omit< + NEW_LocationResolved, + '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 { + records: NEW_MatcherRecordRaw[] = [] +): NEW_RouterMatcher { // TODO: we also need an array that has the correct order - const matchers = new Map() + const matchers = new Map() // 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 + ] + | [ + relativeLocation: string, + currentLocation: NEW_LocationResolved + ] + | [absoluteLocation: MatcherLocationAsPathAbsolute] + | [ + relativeLocation: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved + ] + | [location: MatcherLocationAsNamed] + | [ + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_LocationResolved + ] + + function resolve( + ...args: MatcherResolveArgs + ): NEW_LocationResolved { 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['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 } 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 f40ce00a5..e922e7217 100644 --- a/packages/router/src/new-route-resolver/matchers/test-utils.ts +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -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 diff --git a/packages/router/src/types/typeGuards.ts b/packages/router/src/types/typeGuards.ts index ba30bd9b6..9ecbf3a3c 100644 --- a/packages/router/src/types/typeGuards.ts +++ b/packages/router/src/types/typeGuards.ts @@ -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 { return typeof name === 'string' || typeof name === 'symbol' }