diff --git a/.gitignore b/.gitignore index faa347817..9053a1101 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ local.log _selenium-server.log packages/*/LICENSE tracing_output +tsconfig.vitest-temp.json diff --git a/packages/router/__tests__/location.spec.ts b/packages/router/__tests__/location.spec.ts index 8ccd8a425..7b7497687 100644 --- a/packages/router/__tests__/location.spec.ts +++ b/packages/router/__tests__/location.spec.ts @@ -134,26 +134,121 @@ describe('parseURL', () => { }) }) - it('parses ? after the hash', () => { + it('correctly parses a ? after the hash', () => { expect(parseURL('/foo#?a=one')).toEqual({ fullPath: '/foo#?a=one', path: '/foo', hash: '#?a=one', query: {}, }) - expect(parseURL('/foo/#?a=one')).toEqual({ - fullPath: '/foo/#?a=one', + expect(parseURL('/foo/?a=two#?a=one')).toEqual({ + fullPath: '/foo/?a=two#?a=one', path: '/foo/', hash: '#?a=one', + query: { a: 'two' }, + }) + }) + + it('works with empty query', () => { + expect(parseURL('/foo?#hash')).toEqual({ + fullPath: '/foo?#hash', + path: '/foo', + hash: '#hash', + query: {}, + }) + expect(parseURL('/foo#hash')).toEqual({ + fullPath: '/foo#hash', + path: '/foo', + hash: '#hash', + query: {}, + }) + expect(parseURL('/foo?')).toEqual({ + fullPath: '/foo?', + path: '/foo', + hash: '', query: {}, }) + expect(parseURL('/foo')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + }) + + it('works with empty hash', () => { + expect(parseURL('/foo#')).toEqual({ + fullPath: '/foo#', + path: '/foo', + hash: '#', + query: {}, + }) + expect(parseURL('/foo?#')).toEqual({ + fullPath: '/foo?#', + path: '/foo', + hash: '#', + query: {}, + }) + expect(parseURL('/foo')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + }) + + it('works with a relative paths', () => { + expect(parseURL('foo', '/parent/bar')).toEqual({ + fullPath: '/parent/foo', + path: '/parent/foo', + hash: '', + query: {}, + }) + expect(parseURL('./foo', '/parent/bar')).toEqual({ + fullPath: '/parent/foo', + path: '/parent/foo', + hash: '', + query: {}, + }) + expect(parseURL('../foo', '/parent/bar')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + // cannot go below root + expect(parseURL('../../foo', '/parent/bar')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + + expect(parseURL('', '/parent/bar')).toEqual({ + fullPath: '/parent/bar', + path: '/parent/bar', + hash: '', + query: {}, + }) + expect(parseURL('#foo', '/parent/bar')).toEqual({ + fullPath: '/parent/bar#foo', + path: '/parent/bar', + hash: '#foo', + query: {}, + }) + expect(parseURL('?o=o', '/parent/bar')).toEqual({ + fullPath: '/parent/bar?o=o', + path: '/parent/bar', + hash: '', + query: { o: 'o' }, + }) }) it('calls parseQuery', () => { const parseQuery = vi.fn() originalParseURL(parseQuery, '/?é=é&é=a') expect(parseQuery).toHaveBeenCalledTimes(1) - expect(parseQuery).toHaveBeenCalledWith('é=é&é=a') + expect(parseQuery).toHaveBeenCalledWith('?é=é&é=a') }) }) diff --git a/packages/router/__tests__/router.spec.ts b/packages/router/__tests__/router.spec.ts index bf11f31ba..f835f41e3 100644 --- a/packages/router/__tests__/router.spec.ts +++ b/packages/router/__tests__/router.spec.ts @@ -14,8 +14,6 @@ import { START_LOCATION_NORMALIZED } from '../src/location' import { vi, describe, expect, it, beforeAll } from 'vitest' import { mockWarn } from './vitest-mock-warn' -declare var __DEV__: boolean - const routes: RouteRecordRaw[] = [ { path: '/', component: components.Home, name: 'home' }, { path: '/home', redirect: '/' }, @@ -173,7 +171,7 @@ describe('Router', () => { const parseQuery = vi.fn(_ => ({})) const { router } = await newRouter({ parseQuery }) const to = router.resolve('/foo?bar=baz') - expect(parseQuery).toHaveBeenCalledWith('bar=baz') + expect(parseQuery).toHaveBeenCalledWith('?bar=baz') expect(to.query).toEqual({}) }) diff --git a/packages/router/src/encoding.ts b/packages/router/src/encoding.ts index 69b338a65..74d304928 100644 --- a/packages/router/src/encoding.ts +++ b/packages/router/src/encoding.ts @@ -22,7 +22,7 @@ import { warn } from './warning' const HASH_RE = /#/g // %23 const AMPERSAND_RE = /&/g // %26 -const SLASH_RE = /\//g // %2F +export const SLASH_RE = /\//g // %2F const EQUAL_RE = /=/g // %3D const IM_RE = /\?/g // %3F export const PLUS_RE = /\+/g // %2B @@ -58,7 +58,7 @@ const ENC_SPACE_RE = /%20/g // } * @param text - string to encode * @returns encoded string */ -function commonEncode(text: string | number): string { +export function commonEncode(text: string | number): string { return encodeURI('' + text) .replace(ENC_PIPE_RE, '|') .replace(ENC_BRACKET_OPEN_RE, '[') diff --git a/packages/router/src/errors.ts b/packages/router/src/errors.ts index 877a0de21..63abf5f8a 100644 --- a/packages/router/src/errors.ts +++ b/packages/router/src/errors.ts @@ -1,5 +1,9 @@ import type { MatcherLocationRaw, MatcherLocation } from './types' -import type { RouteLocationRaw, RouteLocationNormalized } from './typed-routes' +import type { + RouteLocationRaw, + RouteLocationNormalized, + RouteLocationNormalizedLoaded, +} from './typed-routes' import { assign } from './utils' /** @@ -199,3 +203,19 @@ function stringifyRoute(to: RouteLocationRaw): string { } return JSON.stringify(location, null, 2) } +/** + * Internal type to define an ErrorHandler + * + * @param error - error thrown + * @param to - location we were navigating to when the error happened + * @param from - location we were navigating from when the error happened + * @internal + */ + +export interface _ErrorListener { + ( + error: any, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): any +} diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts new file mode 100644 index 000000000..654bc3b6d --- /dev/null +++ b/packages/router/src/experimental/router.ts @@ -0,0 +1,1331 @@ +import { + createRouterError, + ErrorTypes, + isNavigationFailure, + NavigationRedirectError, + type _ErrorListener, + type NavigationFailure, +} from '../errors' +import { + nextTick, + shallowReactive, + ShallowRef, + shallowRef, + unref, + warn, + 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_LocationResolved, + NEW_MatcherRecord, + NEW_MatcherRecordRaw, + NEW_RouterResolver, +} from '../new-route-resolver/resolver' +import { + parseQuery as originalParseQuery, + stringifyQuery as originalStringifyQuery, +} from '../query' +import type { Router } from '../router' +import { + _ScrollPositionNormalized, + computeScrollPosition, + getSavedScrollPosition, + getScrollKey, + saveScrollPosition, + scrollToPosition, + type RouterScrollBehavior, +} from '../scrollBehavior' +import type { + NavigationGuardWithThis, + NavigationHookAfter, + RouteLocation, + RouteLocationAsPath, + RouteLocationAsRelative, + RouteLocationAsRelativeTyped, + RouteLocationAsString, + RouteLocationNormalized, + RouteLocationNormalizedLoaded, + RouteLocationRaw, + RouteLocationResolved, + RouteMap, + RouteParams, + RouteRecordNameGeneric, +} from '../typed-routes' +import { + isRouteLocation, + isRouteName, + Lazy, + RouteLocationOptions, + RouteMeta, +} from '../types' +import { useCallbacks } from '../utils/callbacks' +import { + isSameRouteLocation, + parseURL, + START_LOCATION_NORMALIZED, +} from '../location' +import { applyToParams, assign, isArray, isBrowser, noop } from '../utils' +import { decode, encodeParam } from '../encoding' +import { + extractChangingRecords, + extractComponentsGuards, + guardToPromiseFn, +} from '../navigationGuards' +import { addDevtools } from '../devtools' +import { + routeLocationKey, + routerKey, + routerViewLocationKey, +} from '../injectionSymbols' + +/** + * resolve, reject arguments of Promise constructor + * @internal + */ +export type _OnReadyCallback = [() => void, (reason?: any) => void] + +/** + * Options to initialize a {@link Router} instance. + */ +export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions { + /** + * History implementation used by the router. Most web applications should use + * `createWebHistory` but it requires the server to be properly configured. + * You can also use a _hash_ based history with `createWebHashHistory` that + * does not require any configuration on the server but isn't handled at all + * by search engines and does poorly on SEO. + * + * @example + * ```js + * createRouter({ + * history: createWebHistory(), + * // other options... + * }) + * ``` + */ + history: RouterHistory + + /** + * Function to control scrolling when navigating between pages. Can return a + * Promise to delay scrolling. + * + * @see {@link RouterScrollBehavior}. + * + * @example + * ```js + * function scrollBehavior(to, from, savedPosition) { + * // `to` and `from` are both route locations + * // `savedPosition` can be null if there isn't one + * } + * ``` + */ + scrollBehavior?: RouterScrollBehavior + + /** + * Custom implementation to parse a query. See its counterpart, + * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}. + * + * @example + * Let's say you want to use the [qs package](https://github.com/ljharb/qs) + * to parse queries, you can provide both `parseQuery` and `stringifyQuery`: + * ```js + * import qs from 'qs' + * + * createRouter({ + * // other options... + * parseQuery: qs.parse, + * stringifyQuery: qs.stringify, + * }) + * ``` + */ + parseQuery?: typeof originalParseQuery + + /** + * Custom implementation to stringify a query object. Should not prepend a leading `?`. + * {@link parseQuery} counterpart to handle query parsing. + */ + + stringifyQuery?: typeof originalStringifyQuery + + /** + * Default class applied to active {@link RouterLink}. If none is provided, + * `router-link-active` will be applied. + */ + linkActiveClass?: string + + /** + * Default class applied to exact active {@link RouterLink}. If none is provided, + * `router-link-exact-active` will be applied. + */ + linkExactActiveClass?: string + + /** + * Default class applied to non-active {@link RouterLink}. If none is provided, + * `router-link-inactive` will be applied. + */ + // linkInactiveClass?: string +} + +/** + * 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 + + /** + * Matcher to use to resolve routes. + * @experimental + */ + matcher: NEW_RouterResolver +} + +/** + * Router base instance. + * @experimental This version is not stable, it's meant to replace {@link Router} in the future. + */ +export interface EXPERIMENTAL_Router_Base { + /** + * Current {@link RouteLocationNormalized} + */ + readonly currentRoute: ShallowRef + + /** + * Allows turning off the listening of history events. This is a low level api for micro-frontend. + */ + 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 + * + * @param name - Name of the route to check + */ + hasRoute(name: NonNullable): boolean + + /** + * Get a full list of all the {@link RouteRecord | route records}. + */ + getRoutes(): TRouteRecord[] + + /** + * Delete all routes from the router matcher. + */ + clearRoutes(): void + + /** + * Returns the {@link RouteLocation | normalized version} of a + * {@link RouteLocationRaw | route location}. Also includes an `href` property + * that includes any existing `base`. By default, the `currentLocation` used is + * `router.currentRoute` and should only be overridden in advanced use cases. + * + * @param to - Raw route location to resolve + * @param currentLocation - Optional current location to resolve against + */ + resolve( + to: RouteLocationAsRelativeTyped, + // NOTE: This version doesn't work probably because it infers the type too early + // | RouteLocationAsRelative + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved + resolve( + // not having the overload produces errors in RouterLink calls to router.resolve() + to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath, + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved + + /** + * Programmatically navigate to a new URL by pushing an entry in the history + * stack. + * + * @param to - Route location to navigate to + */ + push(to: RouteLocationRaw): Promise + + /** + * Programmatically navigate to a new URL by replacing the current entry in + * the history stack. + * + * @param to - Route location to navigate to + */ + replace(to: RouteLocationRaw): Promise + + /** + * Go back in history if possible by calling `history.back()`. Equivalent to + * `router.go(-1)`. + */ + back(): void + + /** + * Go forward in history if possible by calling `history.forward()`. + * Equivalent to `router.go(1)`. + */ + forward(): void + + /** + * Allows you to move forward or backward through the history. Calls + * `history.go()`. + * + * @param delta - The position in the history to which you want to move, + * relative to the current page + */ + go(delta: number): void + + /** + * Add a navigation guard that executes before any navigation. Returns a + * function that removes the registered guard. + * + * @param guard - navigation guard to add + */ + beforeEach(guard: NavigationGuardWithThis): () => void + + /** + * Add a navigation guard that executes before navigation is about to be + * resolved. At this state all component have been fetched and other + * navigation guards have been successful. Returns a function that removes the + * registered guard. + * + * @param guard - navigation guard to add + * @returns a function that removes the registered guard + * + * @example + * ```js + * router.beforeResolve(to => { + * if (to.meta.requiresAuth && !isAuthenticated) return false + * }) + * ``` + * + */ + beforeResolve(guard: NavigationGuardWithThis): () => void + + /** + * Add a navigation hook that is executed after every navigation. Returns a + * function that removes the registered hook. + * + * @param guard - navigation hook to add + * @returns a function that removes the registered hook + * + * @example + * ```js + * router.afterEach((to, from, failure) => { + * if (isNavigationFailure(failure)) { + * console.log('failed navigation', failure) + * } + * }) + * ``` + */ + afterEach(guard: NavigationHookAfter): () => void + + /** + * Adds an error handler that is called every time a non caught error happens + * during navigation. This includes errors thrown synchronously and + * asynchronously, errors returned or passed to `next` in any navigation + * guard, and errors occurred when trying to resolve an async component that + * is required to render a route. + * + * @param handler - error handler to register + */ + onError(handler: _ErrorListener): () => void + + /** + * Returns a Promise that resolves when the router has completed the initial + * navigation, which means it has resolved all async enter hooks and async + * components that are associated with the initial route. If the initial + * navigation already happened, the promise resolves immediately. + * + * This is useful in server-side rendering to ensure consistent output on both + * the server and the client. Note that on server side, you need to manually + * push the initial location while on client side, the router automatically + * picks it up from the URL. + */ + isReady(): Promise + + /** + * Called automatically by `app.use(router)`. Should not be called manually by + * the user. This will trigger the initial navigation when on client side. + * + * @internal + * @param app - Application that uses the router + */ + install(app: App): void +} + +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 +} + +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 { + /** + * Arbitrary data attached to the record. + */ + meta: RouteMeta + parent?: EXPERIMENTAL_RouteRecordNormalized +} + +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_Router< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecordNormalized +> { + const { + matcher, + parseQuery = originalParseQuery, + stringifyQuery = originalStringifyQuery, + history: routerHistory, + } = options + + if (__DEV__ && !routerHistory) + throw new Error( + 'Provide the "history" option when calling "createRouter()":' + + ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history' + ) + + const beforeGuards = useCallbacks>() + const beforeResolveGuards = useCallbacks>() + const afterGuards = useCallbacks() + const currentRoute = shallowRef( + START_LOCATION_NORMALIZED + ) + let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED + + // leave the scrollRestoration if no scrollBehavior is provided + if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) { + history.scrollRestoration = 'manual' + } + + const normalizeParams = applyToParams.bind( + null, + paramValue => '' + paramValue + ) + const encodeParams = applyToParams.bind(null, encodeParam) + const decodeParams: (params: RouteParams | undefined) => RouteParams = + // @ts-expect-error: intentionally avoid the type check + applyToParams.bind(null, decode) + + function addRoute( + parentOrRoute: + | NonNullable + | EXPERIMENTAL_RouteRecordRaw, + route?: EXPERIMENTAL_RouteRecordRaw + ) { + let parent: Parameters<(typeof matcher)['addMatcher']>[1] | undefined + let rawRecord: EXPERIMENTAL_RouteRecordRaw + + if (isRouteName(parentOrRoute)) { + parent = matcher.getMatcher(parentOrRoute) + if (__DEV__ && !parent) { + warn( + `Parent route "${String( + parentOrRoute + )}" not found when adding child route`, + route + ) + } + rawRecord = route! + } else { + rawRecord = parentOrRoute + } + + const addedRecord = matcher.addMatcher( + normalizeRouteRecord(rawRecord), + parent + ) + + return () => { + matcher.removeMatcher(addedRecord) + } + } + + function removeRoute(name: NonNullable) { + const recordMatcher = matcher.getMatcher(name) + if (recordMatcher) { + matcher.removeMatcher(recordMatcher) + } else if (__DEV__) { + warn(`Cannot remove non-existent route "${String(name)}"`) + } + } + + function getRoutes() { + return matcher.getMatchers() + } + + function hasRoute(name: NonNullable): boolean { + return !!matcher.getMatcher(name) + } + + function resolve( + rawLocation: RouteLocationRaw, + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved { + // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => { + // const objectLocation = routerLocationAsObject(rawLocation) + // we create a copy to modify it later + // TODO: in the experimental version, allow configuring this + currentLocation = + currentLocation && assign({}, currentLocation || currentRoute.value) + // currentLocation = assign({}, currentLocation || currentRoute.value) + + if (__DEV__) { + if (!isRouteLocation(rawLocation)) { + warn( + `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, + rawLocation + ) + return resolve({}) + } + + if ( + typeof rawLocation === 'object' && + !rawLocation.hash?.startsWith('#') + ) { + warn( + `A \`hash\` should always start with the character "#". Replace "${rawLocation.hash}" with "#${rawLocation.hash}".` + ) + } + } + + // 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) + + if (__DEV__) { + if (href.startsWith('//')) { + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + } + if (!matchedRoute.matched.length) { + warn(`No match found for location with path "${rawLocation}"`) + } + } + + // 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( + to: RouteLocationRaw | RouteLocationNormalized + ): Exclude | RouteLocationNormalized { + return typeof to === 'string' + ? parseURL(parseQuery, to, currentRoute.value.path) + : assign({}, to) + } + + function checkCanceledNavigation( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): NavigationFailure | void { + if (pendingLocation !== to) { + return createRouterError( + ErrorTypes.NAVIGATION_CANCELLED, + { + from, + to, + } + ) + } + } + + function push(to: RouteLocationRaw) { + return pushWithRedirect(to) + } + + function replace(to: RouteLocationRaw) { + return pushWithRedirect(to, true) + } + + function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void { + const lastMatched = to.matched[to.matched.length - 1] + if (lastMatched && lastMatched.redirect) { + const { redirect } = lastMatched + let newTargetLocation = + typeof redirect === 'function' ? redirect(to) : redirect + + if (typeof newTargetLocation === 'string') { + newTargetLocation = + newTargetLocation.includes('?') || newTargetLocation.includes('#') + ? (newTargetLocation = locationAsObject(newTargetLocation)) + : // force empty params + { path: newTargetLocation } + // @ts-expect-error: force empty params when a string is passed to let + // the router parse them again + newTargetLocation.params = {} + } + + if ( + __DEV__ && + newTargetLocation.path == null && + !('name' in newTargetLocation) + ) { + warn( + `Invalid redirect found:\n${JSON.stringify( + newTargetLocation, + null, + 2 + )}\n when navigating to "${ + to.fullPath + }". A redirect must contain a name or path. This will break in production.` + ) + throw new Error('Invalid redirect') + } + + return assign( + { + query: to.query, + hash: to.hash, + // avoid transferring params if the redirect has a path + params: newTargetLocation.path != null ? {} : to.params, + }, + newTargetLocation + ) + } + } + + 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 + const replace = (to as RouteLocationOptions).replace ?? _replace + + const shouldRedirect = handleRedirectRecord(targetLocation) + + if (shouldRedirect) + return pushWithRedirect( + assign(locationAsObject(shouldRedirect), { + state: + typeof shouldRedirect === 'object' + ? assign({}, data, shouldRedirect.state) + : data, + force, + }), + replace, + // keep original redirectedFrom if it exists + redirectedFrom || targetLocation + ) + + // if it was a redirect we already called `pushWithRedirect` above + const toLocation = targetLocation as RouteLocationNormalized + + toLocation.redirectedFrom = redirectedFrom + let failure: NavigationFailure | void | undefined + + if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) { + failure = createRouterError( + ErrorTypes.NAVIGATION_DUPLICATED, + { to: toLocation, from } + ) + // trigger scroll to allow scrolling to the same anchor + handleScroll( + from, + from, + // this is a push, the only way for it to be triggered from a + // history.listen is with a redirect, which makes it become a push + true, + // This cannot be the first navigation because the initial location + // cannot be manually navigated to + false + ) + } + + return (failure ? Promise.resolve(failure) : navigate(toLocation, from)) + .catch((error: NavigationFailure | NavigationRedirectError) => + isNavigationFailure(error) + ? // navigation redirects still mark the router as ready + isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ? error + : markAsReady(error) // also returns the error + : // reject any unknown error + triggerError(error, toLocation, from) + ) + .then((failure: NavigationFailure | NavigationRedirectError | void) => { + if (failure) { + if ( + isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + if ( + __DEV__ && + // we are redirecting to the same location we were already at + isSameRouteLocation( + stringifyQuery, + resolve(failure.to), + toLocation + ) && + // and we have done it a couple of times + redirectedFrom && + // @ts-expect-error: added only in dev + (redirectedFrom._count = redirectedFrom._count + ? // @ts-expect-error + redirectedFrom._count + 1 + : 1) > 30 + ) { + warn( + `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.` + ) + return Promise.reject( + new Error('Infinite redirect in navigation guard') + ) + } + + return pushWithRedirect( + // keep options + 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 + ) + } + } else { + // if we fail we don't finalize the navigation + failure = finalizeNavigation( + toLocation as RouteLocationNormalizedLoaded, + from, + true, + replace, + data + ) + } + triggerAfterEach( + toLocation as RouteLocationNormalizedLoaded, + from, + failure + ) + return failure + }) + } + + /** + * Helper to reject and skip all navigation guards if a new navigation happened + * @param to + * @param from + */ + function checkCanceledNavigationAndReject( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): Promise { + const error = checkCanceledNavigation(to, from) + return error ? Promise.reject(error) : Promise.resolve() + } + + 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) + : fn() + } + + // TODO: refactor the whole before guards by internally using router.beforeEach + + function navigate( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + let guards: Lazy[] + + const [leavingRecords, updatingRecords, enteringRecords] = + extractChangingRecords(to, from) + + // all components here have been resolved once because we are leaving + guards = extractComponentsGuards( + leavingRecords.reverse(), + 'beforeRouteLeave', + to, + from + ) + + // leavingRecords is already reversed + for (const record of leavingRecords) { + record.leaveGuards.forEach(guard => { + guards.push(guardToPromiseFn(guard, to, from)) + }) + } + + const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( + null, + to, + from + ) + + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeRouteLeave guards + return ( + runGuardQueue(guards) + .then(() => { + // check global guards beforeEach + guards = [] + for (const guard of beforeGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + guards.push(canceledNavigationCheck) + + return runGuardQueue(guards) + }) + .then(() => { + // check in components beforeRouteUpdate + guards = extractComponentsGuards( + updatingRecords, + 'beforeRouteUpdate', + to, + from + ) + + for (const record of updatingRecords) { + record.updateGuards.forEach(guard => { + guards.push(guardToPromiseFn(guard, to, from)) + }) + } + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // check the route beforeEnter + guards = [] + for (const record of enteringRecords) { + // do not trigger beforeEnter on reused views + if (record.beforeEnter) { + if (isArray(record.beforeEnter)) { + for (const beforeEnter of record.beforeEnter) + guards.push(guardToPromiseFn(beforeEnter, to, from)) + } else { + guards.push(guardToPromiseFn(record.beforeEnter, to, from)) + } + } + } + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // NOTE: at this point to.matched is normalized and does not contain any () => Promise + + // clear existing enterCallbacks, these are added by extractComponentsGuards + to.matched.forEach(record => (record.enterCallbacks = {})) + + // check in-component beforeRouteEnter + guards = extractComponentsGuards( + enteringRecords, + 'beforeRouteEnter', + to, + from, + runWithContext + ) + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // check global guards beforeResolve + guards = [] + for (const guard of beforeResolveGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + guards.push(canceledNavigationCheck) + + return runGuardQueue(guards) + }) + // catch any navigation canceled + .catch(err => + isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED) + ? err + : Promise.reject(err) + ) + ) + } + + function triggerAfterEach( + to: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + failure?: NavigationFailure | void + ): void { + // navigation is confirmed, call afterGuards + // TODO: wrap with error handlers + afterGuards + .list() + .forEach(guard => runWithContext(() => guard(to, from, failure))) + } + + /** + * - Cleans up any navigation guards + * - Changes the url if necessary + * - Calls the scrollBehavior + */ + function finalizeNavigation( + toLocation: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + isPush: boolean, + replace?: boolean, + data?: HistoryState + ): NavigationFailure | void { + // a more recent navigation took place + const error = checkCanceledNavigation(toLocation, from) + if (error) return error + + // only consider as push if it's not the first navigation + const isFirstNavigation = from === START_LOCATION_NORMALIZED + const state: Partial | null = !isBrowser ? {} : history.state + + // change URL only if the user did a push/replace and if it's not the initial navigation because + // it's just reflecting the url + if (isPush) { + // on the initial navigation, we want to reuse the scroll position from + // history state if it exists + if (replace || isFirstNavigation) + routerHistory.replace( + toLocation.fullPath, + assign( + { + scroll: isFirstNavigation && state && state.scroll, + }, + data + ) + ) + else routerHistory.push(toLocation.fullPath, data) + } + + // accept current navigation + currentRoute.value = toLocation + handleScroll(toLocation, from, isPush, isFirstNavigation) + + markAsReady() + } + + let removeHistoryListener: undefined | null | (() => void) + // attach listener to history to trigger navigations + function setupListeners() { + // avoid setting up listeners twice due to an invalid first navigation + if (removeHistoryListener) return + removeHistoryListener = routerHistory.listen((to, _from, info) => { + if (!router.listening) return + // cannot be a redirect route because it was in history + const toLocation = resolve(to) as RouteLocationNormalized + + // due to dynamic routing, and to hash history with manual navigation + // (manually changing the url or calling history.hash = '#/somewhere'), + // there could be a redirect record in history + const shouldRedirect = handleRedirectRecord(toLocation) + if (shouldRedirect) { + pushWithRedirect( + assign(shouldRedirect, { force: true }), + true, + toLocation + ).catch(noop) + return + } + + pendingLocation = toLocation + const from = currentRoute.value + + // TODO: should be moved to web history? + if (isBrowser) { + saveScrollPosition( + getScrollKey(from.fullPath, info.delta), + computeScrollPosition() + ) + } + + navigate(toLocation, from) + .catch((error: NavigationFailure | NavigationRedirectError) => { + if ( + isNavigationFailure( + error, + ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_CANCELLED + ) + ) { + return error + } + if ( + isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + // Here we could call if (info.delta) routerHistory.go(-info.delta, + // false) but this is bug prone as we have no way to wait the + // navigation to be finished before calling pushWithRedirect. Using + // a setTimeout of 16ms seems to work but there is no guarantee for + // it to work on every browser. So instead we do not restore the + // history entry and trigger a new navigation as requested by the + // navigation guard. + + // the error is already handled by router.push we just want to avoid + // logging the error + pushWithRedirect( + assign(locationAsObject((error as NavigationRedirectError).to), { + force: true, + }), + undefined, + toLocation + // avoid an uncaught rejection, let push call triggerError + ) + .then(failure => { + // manual change in hash history #916 ending up in the URL not + // changing, but it was changed by the manual url change, so we + // need to manually change it ourselves + if ( + isNavigationFailure( + failure, + ErrorTypes.NAVIGATION_ABORTED | + ErrorTypes.NAVIGATION_DUPLICATED + ) && + !info.delta && + info.type === NavigationType.pop + ) { + routerHistory.go(-1, false) + } + }) + .catch(noop) + // avoid the then branch + return Promise.reject() + } + // do not restore history on unknown direction + if (info.delta) { + routerHistory.go(-info.delta, false) + } + // unrecognized error, transfer to the global handler + return triggerError(error, toLocation, from) + }) + .then((failure: NavigationFailure | void) => { + failure = + failure || + finalizeNavigation( + // after navigation, all matched components are resolved + toLocation as RouteLocationNormalizedLoaded, + from, + false + ) + + // revert the navigation + if (failure) { + if ( + info.delta && + // a new navigation has been triggered, so we do not want to revert, that will change the current history + // entry while a different route is displayed + !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) + ) { + routerHistory.go(-info.delta, false) + } else if ( + info.type === NavigationType.pop && + isNavigationFailure( + failure, + ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_DUPLICATED + ) + ) { + // manual change in hash history #916 + // it's like a push but lacks the information of the direction + routerHistory.go(-1, false) + } + } + + triggerAfterEach( + toLocation as RouteLocationNormalizedLoaded, + from, + failure + ) + }) + // avoid warnings in the console about uncaught rejections, they are logged by triggerErrors + .catch(noop) + }) + } + + // Initialization and Errors + let readyHandlers = useCallbacks<_OnReadyCallback>() + let errorListeners = useCallbacks<_ErrorListener>() + let ready: boolean + + /** + * Trigger errorListeners added via onError and throws the error as well + * + * @param error - error to throw + * @param to - location we were navigating to when the error happened + * @param from - location we were navigating from when the error happened + * @returns the error as a rejected promise + */ + function triggerError( + error: any, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + markAsReady(error) + const list = errorListeners.list() + if (list.length) { + list.forEach(handler => handler(error, to, from)) + } else { + if (__DEV__) { + warn('uncaught error during route navigation:') + } + console.error(error) + } + // reject the error no matter there were error listeners or not + return Promise.reject(error) + } + + function isReady(): Promise { + if (ready && currentRoute.value !== START_LOCATION_NORMALIZED) + return Promise.resolve() + return new Promise((resolve, reject) => { + readyHandlers.add([resolve, reject]) + }) + } + + /** + * Mark the router as ready, resolving the promised returned by isReady(). Can + * only be called once, otherwise does nothing. + * @param err - optional error + */ + function markAsReady(err: E): E + function markAsReady(): void + function markAsReady(err?: E): E | void { + if (!ready) { + // still not ready if an error happened + ready = !err + setupListeners() + readyHandlers + .list() + .forEach(([resolve, reject]) => (err ? reject(err) : resolve())) + readyHandlers.reset() + } + return err + } + + // Scroll behavior + function handleScroll( + to: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + isPush: boolean, + isFirstNavigation: boolean + ): // the return is not meant to be used + Promise { + const { scrollBehavior } = options + if (!isBrowser || !scrollBehavior) return Promise.resolve() + + const scrollPosition: _ScrollPositionNormalized | null = + (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) || + ((isFirstNavigation || !isPush) && + (history.state as HistoryState) && + history.state.scroll) || + null + + return nextTick() + .then(() => scrollBehavior(to, from, scrollPosition)) + .then(position => position && scrollToPosition(position)) + .catch(err => triggerError(err, to, from)) + } + + const go = (delta: number) => routerHistory.go(delta) + + let started: boolean | undefined + const installedApps = new Set() + + const router: EXPERIMENTAL_Router< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecordNormalized + > = { + currentRoute, + listening: true, + + addRoute, + removeRoute, + clearRoutes: matcher.clearMatchers, + hasRoute, + getRoutes, + resolve, + options, + + push, + replace, + go, + back: () => go(-1), + forward: () => go(1), + + beforeEach: beforeGuards.add, + beforeResolve: beforeResolveGuards.add, + afterEach: afterGuards.add, + + onError: errorListeners.add, + isReady, + + install(app: App) { + const router = this + 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, + get: () => unref(currentRoute), + }) + + // this initial navigation is only necessary on client, on server it doesn't + // make sense because it will create an extra unnecessary navigation and could + // lead to problems + if ( + isBrowser && + // used for the initial navigation client side to avoid pushing + // multiple times when the router is used in multiple apps + !started && + currentRoute.value === START_LOCATION_NORMALIZED + ) { + // see above + started = true + push(routerHistory.location).catch(err => { + if (__DEV__) warn('Unexpected error when starting the router:', err) + }) + } + + const reactiveRoute = {} as RouteLocationNormalizedLoaded + for (const key in START_LOCATION_NORMALIZED) { + Object.defineProperty(reactiveRoute, key, { + get: () => currentRoute.value[key as keyof RouteLocationNormalized], + enumerable: true, + }) + } + + // @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) + + const unmountApp = app.unmount + installedApps.add(app) + app.unmount = function () { + installedApps.delete(app) + // the router is not attached to an app anymore + if (installedApps.size < 1) { + // invalidate the current navigation + pendingLocation = START_LOCATION_NORMALIZED + removeHistoryListener && removeHistoryListener() + removeHistoryListener = null + currentRoute.value = START_LOCATION_NORMALIZED + 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) { + // @ts-expect-error: FIXME: refactor with new types once it's possible + addDevtools(app, router, matcher) + } + }, + } + + // TODO: type this as NavigationGuardReturn or similar instead of any + function runGuardQueue(guards: Lazy[]): Promise { + return guards.reduce( + (promise, guard) => promise.then(() => runWithContext(guard)), + Promise.resolve() + ) + } + + 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/index.ts b/packages/router/src/index.ts index 2a62ad156..88e9ce732 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -137,7 +137,8 @@ export type { } from './typed-routes' export { createRouter } from './router' -export type { Router, RouterOptions, RouterScrollBehavior } from './router' +export type { Router, RouterOptions } from './router' +export type { RouterScrollBehavior } from './scrollBehavior' export { NavigationFailureType, isNavigationFailure } from './errors' export type { diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 08c2b744b..163bf6f8d 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -3,7 +3,7 @@ import { RouteParamValue, RouteParamsGeneric } from './types' import { RouteRecord } from './matcher/types' import { warn } from './warning' import { isArray } from './utils' -import { decode } from './encoding' +import { decode, encodeHash } from './encoding' import { RouteLocation, RouteLocationNormalizedLoaded } from './typed-routes' /** @@ -50,43 +50,69 @@ export function parseURL( searchString = '', hash = '' - // Could use URL and URLSearchParams but IE 11 doesn't support it - // TODO: move to new URL() + // NOTE: we could use URL and URLSearchParams but they are 2 to 5 times slower than this method const hashPos = location.indexOf('#') let searchPos = location.indexOf('?') - // the hash appears before the search, so it's not part of the search string - if (hashPos < searchPos && hashPos >= 0) { - searchPos = -1 - } - if (searchPos > -1) { + // This ensures that the ? is not part of the hash + // e.g. /foo#hash?query -> has no query + searchPos = hashPos >= 0 && searchPos > hashPos ? -1 : searchPos + + if (searchPos >= 0) { path = location.slice(0, searchPos) + // keep the ? char searchString = location.slice( - searchPos + 1, - hashPos > -1 ? hashPos : location.length + searchPos, + // hashPos cannot be 0 because there is a search section in the location + hashPos > 0 ? hashPos : location.length ) query = parseQuery(searchString) } - if (hashPos > -1) { + if (hashPos >= 0) { + // TODO(major): path ||= path = path || location.slice(0, hashPos) // keep the # character hash = location.slice(hashPos, location.length) } - // no search and no query - path = resolveRelativePath(path != null ? path : location, currentLocation) - // empty path means a relative query or hash `?foo=f`, `#thing` + // TODO(major): path ?? location + path = resolveRelativePath( + path != null + ? path + : // empty path means a relative query or hash `?foo=f`, `#thing` + location, + currentLocation + ) return { - fullPath: path + (searchString && '?') + searchString + hash, + fullPath: path + searchString + hash, path, query, hash: decode(hash), } } +/** + * Creates a `fullPath` property from the `path`, `query` and `hash` properties + * + * @param stringifyQuery - custom function to stringify the query object. It should handle encoding values + * @param path - An encdoded path + * @param query - A decoded query object + * @param hash - A decoded hash + * @returns a valid `fullPath` + */ +export function NEW_stringifyURL( + stringifyQuery: (query?: LocationQueryRaw) => string, + path: LocationPartial['path'], + query?: LocationPartial['query'], + hash: LocationPartial['hash'] = '' +): string { + const searchText = stringifyQuery(query) + return path + (searchText && '?') + searchText + encodeHash(hash) +} + /** * Stringifies a URL object * @@ -207,11 +233,12 @@ export function resolveRelativePath(to: string, from: string): string { return to } + // resolve to: '' with from: '/anything' -> '/anything' if (!to) return from const fromSegments = from.split('/') const toSegments = to.split('/') - const lastToSegment = toSegments[toSegments.length - 1] + const lastToSegment: string | undefined = toSegments[toSegments.length - 1] // make . and ./ the same (../ === .., ../../ === ../..) // this is the same behavior as new URL() diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index 9d787ddbc..1a2541d72 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -14,10 +14,13 @@ import type { _PathParserOptions, } from './pathParserRanker' -import { comparePathParserScore } from './pathParserRanker' +import { + comparePathParserScore, + PATH_PARSER_OPTIONS_DEFAULTS, +} from './pathParserRanker' import { warn } from '../warning' -import { assign, noop } from '../utils' +import { assign, mergeOptions, noop } from '../utils' import type { RouteRecordNameGeneric, _RouteRecordProps } from '../typed-routes' /** @@ -64,8 +67,8 @@ export function createRouterMatcher( NonNullable, RouteRecordMatcher >() - globalOptions = mergeOptions( - { strict: false, end: true, sensitive: false } as PathParserOptions, + globalOptions = mergeOptions( + PATH_PARSER_OPTIONS_DEFAULTS, globalOptions ) @@ -271,7 +274,7 @@ export function createRouterMatcher( name = matcher.record.name params = assign( // paramsFromLocation is a new object - paramsFromLocation( + pickParams( currentLocation.params, // only keep params that exist in the resolved location // only keep optional params coming from a parent record @@ -285,7 +288,7 @@ export function createRouterMatcher( // discard any existing params in the current location that do not exist here // #1497 this ensures better active/exact matching location.params && - paramsFromLocation( + pickParams( location.params, matcher.keys.map(k => k.name) ) @@ -365,7 +368,13 @@ export function createRouterMatcher( } } -function paramsFromLocation( +/** + * Picks an object param to contain only specified keys. + * + * @param params - params object to pick from + * @param keys - keys to pick + */ +function pickParams( params: MatcherLocation['params'], keys: string[] ): MatcherLocation['params'] { @@ -423,7 +432,7 @@ export function normalizeRouteRecord( * components. Also accept a boolean for components. * @param record */ -function normalizeRecordProps( +export function normalizeRecordProps( record: RouteRecordRaw ): Record { const propsObject = {} as Record @@ -466,18 +475,6 @@ function mergeMetaFields(matched: MatcherLocation['matched']) { ) } -function mergeOptions( - defaults: T, - partialOptions: Partial -): T { - const options = {} as T - for (const key in defaults) { - options[key] = key in partialOptions ? partialOptions[key]! : defaults[key] - } - - return options -} - type ParamKey = RouteRecordMatcher['keys'][number] function isSameParam(a: ParamKey, b: ParamKey): boolean { @@ -515,7 +512,7 @@ function checkSameParams(a: RouteRecordMatcher, b: RouteRecordMatcher) { * @param mainNormalizedRecord - RouteRecordNormalized * @param parent - RouteRecordMatcher */ -function checkChildMissingNameWithEmptyPath( +export function checkChildMissingNameWithEmptyPath( mainNormalizedRecord: RouteRecordNormalized, parent?: RouteRecordMatcher ) { diff --git a/packages/router/src/matcher/pathMatcher.ts b/packages/router/src/matcher/pathMatcher.ts index aae2b7826..a4e00f7cf 100644 --- a/packages/router/src/matcher/pathMatcher.ts +++ b/packages/router/src/matcher/pathMatcher.ts @@ -16,6 +16,32 @@ export interface RouteRecordMatcher extends PathParser { alias: RouteRecordMatcher[] } +export function NEW_createRouteRecordMatcher( + record: Readonly, + parent: RouteRecordMatcher | undefined, + options?: PathParserOptions +): RouteRecordMatcher { + const parser = tokensToParser(tokenizePath(record.path), options) + + const matcher: RouteRecordMatcher = assign(parser, { + record, + parent, + // these needs to be populated by the parent + children: [], + alias: [], + }) + + if (parent) { + // both are aliases or both are not aliases + // we don't want to mix them because the order is used when + // passing originalRecord in Matcher.addRoute + if (!matcher.record.aliasOf === !parent.record.aliasOf) + parent.children.push(matcher) + } + + return matcher +} + export function createRouteRecordMatcher( record: Readonly, parent: RouteRecordMatcher | undefined, diff --git a/packages/router/src/matcher/pathParserRanker.ts b/packages/router/src/matcher/pathParserRanker.ts index 81b077642..b2c0b40a0 100644 --- a/packages/router/src/matcher/pathParserRanker.ts +++ b/packages/router/src/matcher/pathParserRanker.ts @@ -367,3 +367,8 @@ function isLastScoreNegative(score: PathParser['score']): boolean { const last = score[score.length - 1] return score.length > 0 && last[last.length - 1] < 0 } +export const PATH_PARSER_OPTIONS_DEFAULTS: PathParserOptions = { + strict: false, + end: true, + sensitive: false, +} diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 90c079f70..2f314ba67 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -22,6 +22,7 @@ import { matchedRouteKey } from './injectionSymbols' import { RouteRecordNormalized } from './matcher/types' import { isESModule, isRouteComponent } from './utils' import { warn } from './warning' +import { isSameRouteRecord } from './location' function registerGuard( record: RouteRecordNormalized, @@ -393,3 +394,42 @@ export function loadRouteLocation( ) ).then(() => route as RouteLocationNormalizedLoaded) } + +/** + * Split the leaving, updating, and entering records. + * @internal + * + * @param to - Location we are navigating to + * @param from - Location we are navigating from + */ +export function extractChangingRecords( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded +): [ + leavingRecords: RouteRecordNormalized[], + updatingRecords: RouteRecordNormalized[], + enteringRecords: RouteRecordNormalized[] +] { + const leavingRecords: RouteRecordNormalized[] = [] + const updatingRecords: RouteRecordNormalized[] = [] + const enteringRecords: RouteRecordNormalized[] = [] + + const len = Math.max(from.matched.length, to.matched.length) + for (let i = 0; i < len; i++) { + const recordFrom = from.matched[i] + if (recordFrom) { + if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) + updatingRecords.push(recordFrom) + else leavingRecords.push(recordFrom) + } + const recordTo = to.matched[i] + if (recordTo) { + // the type doesn't matter because we are comparing per reference + if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) { + enteringRecords.push(recordTo) + } + } + } + + return [leavingRecords, updatingRecords, enteringRecords] +} diff --git a/packages/router/src/new-route-resolver/index.ts b/packages/router/src/new-route-resolver/index.ts new file mode 100644 index 000000000..4c07b32cc --- /dev/null +++ b/packages/router/src/new-route-resolver/index.ts @@ -0,0 +1 @@ +export { createCompiledMatcher } from './resolver' diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts new file mode 100644 index 000000000..f597df07f --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -0,0 +1,59 @@ +import type { LocationQueryRaw } from '../query' +import type { MatcherName } from './resolver' + +/** + * Generic object of params that can be passed to a matcher. + */ +export type MatcherParamsFormatted = Record + +/** + * Empty object in TS. + */ +export type EmptyParams = Record + +export interface MatcherLocationAsNamed { + name: MatcherName + // FIXME: should this be optional? + params: MatcherParamsFormatted + query?: LocationQueryRaw + hash?: string + + /** + * @deprecated This is ignored when `name` is provided + */ + path?: undefined +} + +export interface MatcherLocationAsPathRelative { + path: string + query?: LocationQueryRaw + hash?: string + + /** + * @deprecated This is ignored when `path` is provided + */ + name?: undefined + /** + * @deprecated This is ignored when `path` (instead of `name`) is provided + */ + params?: undefined +} +export interface MatcherLocationAsPathAbsolute + extends MatcherLocationAsPathRelative { + path: `/${string}` +} + +export interface MatcherLocationAsRelative { + params?: MatcherParamsFormatted + query?: LocationQueryRaw + hash?: string + + /** + * @deprecated This location is relative to the next parameter. This `name` will be ignored. + */ + name?: undefined + /** + * @deprecated This location is relative to the next parameter. This `path` will be ignored. + */ + path?: undefined +} diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts new file mode 100644 index 000000000..0f7d8c192 --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -0,0 +1,195 @@ +import { decode, MatcherQueryParams } from './resolver' +import { EmptyParams, MatcherParamsFormatted } from './matcher-location' +import { miss } from './matchers/errors' + +export interface MatcherPatternParams_Base< + TIn = string, + 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 + */ + match(value: TIn): TOut + + /** + * Build a serializable value from parsed params. Should apply encoding if the + * returned value is a string (e.g path and hash should be encoded but query + * shouldn't). + * + * @param value - params value to parse + */ + build(params: TOut): TIn +} + +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 {} + +export class MatcherPatternPathStatic + implements MatcherPatternPath +{ + constructor(private path: string) {} + + match(path: string): EmptyParams { + if (path !== this.path) { + throw miss() + } + return {} + } + + build(): string { + return this.path + } +} +// example of a static matcher built at runtime +// new MatcherPatternPathStatic('/') + +export interface Param_GetSet< + TIn extends string | string[] = string | string[], + TOut = TIn +> { + get?: (value: NoInfer) => TOut + set?: (value: NoInfer) => TIn +} + +export type ParamParser_Generic = + | Param_GetSet + | Param_GetSet +// TODO: these are possible values for optional params +// | null | undefined + +/** + * Type safe helper to define a param parser. + * + * @param parser - the parser to define. Will be returned as is. + */ +/*! #__NO_SIDE_EFFECTS__ */ +export function defineParamParser(parser: { + get?: (value: TIn) => TOut + set?: (value: TOut) => TIn +}): Param_GetSet { + return parser +} + +const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value +const PATH_PARAM_DEFAULT_SET = (value: unknown) => + value && Array.isArray(value) ? value.map(String) : String(value) +// TODO: `(value an null | undefined)` for types + +/** + * NOTE: I tried to make this generic and infer the types from the params but failed. This is what I tried: + * ```ts + * export type ParamsFromParsers

> = { + * [K in keyof P]: P[K] extends Param_GetSet + * ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + * ? TIn + * : TOut + * : never + * } + * + * export class MatcherPatternPathDynamic< + * ParamsParser extends Record + * > implements MatcherPatternPath> + * { + * private params: Record> = {} + * constructor( + * private re: RegExp, + * params: ParamsParser, + * public build: (params: ParamsFromParsers) => string + * ) {} + * ``` + * It ended up not working in one place or another. It could probably be fixed by + */ + +export type ParamsFromParsers

> = { + [K in keyof P]: P[K] extends Param_GetSet + ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + ? TIn + : TOut + : never +} + +export class MatcherPatternPathDynamic< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> implements MatcherPatternPath +{ + private params: Record> = {} + constructor( + private re: RegExp, + params: Record, + public build: (params: TParams) => string, + private opts: { repeat?: boolean; optional?: boolean } = {} + ) { + for (const paramName in params) { + const param = params[paramName] + this.params[paramName] = { + get: param.get || PATH_PARAM_DEFAULT_GET, + // @ts-expect-error FIXME: should work + set: param.set || PATH_PARAM_DEFAULT_SET, + } + } + } + + /** + * Match path against the pattern and return + * + * @param path - path to match + * @throws if the patch doesn't match + * @returns matched decoded params + */ + match(path: string): TParams { + const match = path.match(this.re) + if (!match) { + throw miss() + } + let i = 1 // index in match array + const params = {} as TParams + for (const paramName in this.params) { + const currentParam = this.params[paramName] + const currentMatch = match[i++] + let value: string | null | string[] = + this.opts.optional && currentMatch == null ? null : currentMatch + value = this.opts.repeat && value ? value.split('/') : value + + params[paramName as keyof typeof params] = currentParam.get( + // @ts-expect-error: FIXME: the type of currentParam['get'] is wrong + value && (Array.isArray(value) ? value.map(decode) : decode(value)) + ) as (typeof params)[keyof typeof params] + } + + if (__DEV__ && i !== match.length) { + console.warn( + `Regexp matched ${match.length} params, but ${i} params are defined` + ) + } + return params + } + + // build(params: TParams): string { + // let path = this.re.source + // for (const param of this.params) { + // const value = params[param.name as keyof TParams] + // if (value == null) { + // throw new Error(`Matcher build: missing param ${param.name}`) + // } + // path = path.replace( + // /([^\\]|^)\([^?]*\)/, + // `$1${encodeParam(param.set(value))}` + // ) + // } + // return path + // } +} + +export interface MatcherPatternQuery< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> extends MatcherPatternParams_Base {} + +export interface MatcherPatternHash< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> extends MatcherPatternParams_Base {} diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts new file mode 100644 index 000000000..6f9914af2 --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -0,0 +1,1439 @@ +import { createRouterMatcher, normalizeRouteRecord } from '../matcher' +import { RouteComponent, RouteRecordRaw, MatcherLocation } from '../types' +import { defineComponent } from 'vue' +import { START_LOCATION_NORMALIZED } from '../location' +import { describe, expect, it } from 'vitest' +import { mockWarn } from '../../__tests__/vitest-mock-warn' +import { + createCompiledMatcher, + MatcherLocationRaw, + NEW_MatcherRecordRaw, + NEW_LocationResolved, + NEW_MatcherRecord, +} from './resolver' +import { PathParams, tokensToParser } from '../matcher/pathParserRanker' +import { tokenizePath } from '../matcher/pathTokenizer' +import { miss } from './matchers/errors' +import { MatcherPatternPath } from './matcher-pattern' + +// for raw route record +const component: RouteComponent = defineComponent({}) +// for normalized route records +const components = { default: component } + +function compileRouteRecord( + record: RouteRecordRaw, + parentRecord?: RouteRecordRaw +): NEW_MatcherRecordRaw { + // we adapt the path to ensure they are absolute + // TODO: aliases? they could be handled directly in the path matcher + const path = record.path.startsWith('/') + ? record.path + : (parentRecord?.path || '') + record.path + record.path = path + const parser = tokensToParser(tokenizePath(record.path), { + // start: true, + end: record.end, + sensitive: record.sensitive, + strict: record.strict, + }) + + return { + name: record.name, + + path: { + match(value) { + const params = parser.parse(value) + if (params) { + return params + } + throw miss() + }, + build(params) { + // TODO: normalize params? + return parser.stringify(params) + }, + } satisfies MatcherPatternPath, + + children: record.children?.map(childRecord => + compileRouteRecord(childRecord, record) + ), + } +} + +describe('RouterMatcher.resolve', () => { + mockWarn() + type Matcher = ReturnType + type MatcherResolvedLocation = ReturnType + + const START_LOCATION: MatcherResolvedLocation = { + name: Symbol('START'), + params: {}, + path: '/', + fullPath: '/', + query: {}, + hash: '', + matched: [], + // meta: {}, + } + + function isMatcherLocationResolved( + location: unknown + ): location is NEW_LocationResolved { + return !!( + location && + typeof location === 'object' && + 'matched' in location && + 'fullPath' in location && + Array.isArray(location.matched) + ) + } + + // TODO: rework with object param for clarity + + function assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + toLocation: MatcherLocationRaw, + expectedLocation: Partial, + fromLocation: + | NEW_LocationResolved + | Exclude + | `/${string}` = START_LOCATION + ) { + const records = (Array.isArray(record) ? record : [record]).map( + (record): NEW_MatcherRecordRaw => compileRouteRecord(record) + ) + const matcher = createCompiledMatcher() + for (const record of records) { + matcher.addMatcher(record) + } + + const resolved: MatcherResolvedLocation = { + // FIXME: to add later + // meta: records[0].meta || {}, + path: + typeof toLocation === 'string' ? toLocation : toLocation.path || '/', + name: expect.any(Symbol) as symbol, + matched: [], // FIXME: build up + params: (typeof toLocation === 'object' && toLocation.params) || {}, + ...expectedLocation, + } + + Object.defineProperty(resolved, 'matched', { + writable: true, + configurable: true, + enumerable: false, + value: [], + }) + + fromLocation = isMatcherLocationResolved(fromLocation) + ? fromLocation + : matcher.resolve(fromLocation) + + expect(matcher.resolve(toLocation, fromLocation)).toMatchObject({ + // avoid undesired properties + query: {}, + hash: '', + ...resolved, + }) + } + + /** + * + * @param record - Record or records we are testing the matcher against + * @param location - location we want to resolve against + * @param [start] Optional currentLocation used when resolving + * @returns error + */ + function assertErrorMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + assertRecordMatch(record, location, {}, start) + } + + describe.skip('LocationAsPath', () => { + it('resolves a normal path', () => { + assertRecordMatch({ path: '/', name: 'Home', components }, '/', { + name: 'Home', + path: '/', + params: {}, + }) + }) + + it('resolves a normal path without name', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/' }, + { name: undefined, path: '/', params: {} } + ) + }) + + it('resolves a path with params', () => { + assertRecordMatch( + { path: '/users/:id', name: 'User', components }, + { path: '/users/posva' }, + { name: 'User', params: { id: 'posva' } } + ) + }) + + it('resolves an array of params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: ['b', 'c', 'd'] } }, + { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } } + ) + }) + + it('resolves single params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: 'b' } }, + { name: 'a', path: '/a/b', params: { p: 'b' } } + ) + }) + + it('keeps repeated params as a single one when provided through path', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { path: '/a/b/c' }, + { name: 'a', params: { p: ['b', 'c'] } } + ) + }) + + it('resolves a path with multiple params', () => { + assertRecordMatch( + { path: '/users/:id/:other', name: 'User', components }, + { path: '/users/posva/hey' }, + { name: 'User', params: { id: 'posva', other: 'hey' } } + ) + }) + + it('resolves a path with multiple params but no name', () => { + assertRecordMatch( + { path: '/users/:id/:other', components }, + { path: '/users/posva/hey' }, + { name: undefined, params: { id: 'posva', other: 'hey' } } + ) + }) + + it('returns an empty match when the path does not exist', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/foo' }, + { name: undefined, params: {}, path: '/foo', matched: [] } + ) + }) + + it('allows an optional trailing slash', () => { + assertRecordMatch( + { path: '/home/', name: 'Home', components }, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('allows an optional trailing slash with optional param', () => { + assertRecordMatch( + { path: '/:a', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: 'a' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a', components, name: 'a' }, + { path: '/a/a/' }, + { path: '/a/a/', params: { a: 'a' }, name: 'a' } + ) + }) + + it('allows an optional trailing slash with missing optional param', () => { + assertRecordMatch( + { path: '/:a?', components, name: 'a' }, + { path: '/' }, + { path: '/', params: { a: '' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a?', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: '' }, name: 'a' } + ) + }) + + it('keeps required trailing slash (strict: true)', () => { + const record = { + path: '/home/', + name: 'Home', + components, + options: { strict: true }, + } + assertErrorMatch(record, { path: '/home' }) + assertRecordMatch( + record, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('rejects a trailing slash when strict', () => { + const record = { + path: '/home', + name: 'Home', + components, + options: { strict: true }, + } + assertRecordMatch( + record, + { path: '/home' }, + { name: 'Home', path: '/home', matched: expect.any(Array) } + ) + assertErrorMatch(record, { path: '/home/' }) + }) + }) + + describe('LocationAsName', () => { + it('matches a name', () => { + assertRecordMatch( + { path: '/home', name: 'Home', components }, + // TODO: allow a name only without the params? + { name: 'Home', params: {} }, + { name: 'Home', path: '/home' } + ) + }) + + it('matches a name and fill params', () => { + assertRecordMatch( + { path: '/users/:id/m/:role', name: 'UserEdit', components }, + { name: 'UserEdit', params: { id: 'posva', role: 'admin' } }, + { + name: 'UserEdit', + path: '/users/posva/m/admin', + params: { id: 'posva', role: 'admin' }, + } + ) + }) + + it('throws if the named route does not exists', () => { + expect(() => + assertErrorMatch( + { path: '/', components }, + { name: 'Home', params: {} } + ) + ).toThrowError('Matcher "Home" not found') + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/A/b', params: { a: 'A', b: 'b' } }, + '/A/B' + ) + }) + + // TODO: new matcher no longer allows implicit param merging + it.todo('only keep existing params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { name: 'p', params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + '/a/c' + ) + }) + + // TODO: implement parent children + it.todo('keep optional params from parent record', () => { + const Child_A = { path: 'a', name: 'child_a', components } + const Child_B = { path: 'b', name: 'child_b', components } + const Parent = { + path: '/:optional?/parent', + name: 'parent', + components, + children: [Child_A, Child_B], + } + assertRecordMatch( + Parent, + { name: 'child_b' }, + { + name: 'child_b', + path: '/foo/parent/b', + params: { optional: 'foo' }, + matched: [ + Parent as any, + { + ...Child_B, + path: `${Parent.path}/${Child_B.path}`, + }, + ], + }, + { + params: { optional: 'foo' }, + path: '/foo/parent/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + + // TODO: check if needed by the active matching, if not just test that the param is dropped + it.todo('discards non existent params', () => { + assertRecordMatch( + { path: '/', name: 'home', components }, + { name: 'home', params: { a: 'a', b: 'b' } }, + { name: 'home', path: '/', params: {} } + ) + expect('invalid param(s) "a", "b" ').toHaveBeenWarned() + assertRecordMatch( + { path: '/:b', name: 'a', components }, + { name: 'a', params: { a: 'a', b: 'b' } }, + { name: 'a', path: '/b', params: { b: 'b' } } + ) + expect('invalid param(s) "a"').toHaveBeenWarned() + }) + + it('drops optional params in absolute location', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b' } }, + { name: 'p', path: '/b', params: { a: 'b' } } + ) + }) + + it('keeps optional params passed as empty strings', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b', b: '' } }, + { name: 'p', path: '/b', params: { a: 'b', b: '' } } + ) + }) + + it('resolves root path with optional params', () => { + assertRecordMatch( + { path: '/:tab?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + assertRecordMatch( + { path: '/:tab?/:other?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + }) + }) + + describe.skip('LocationAsRelative', () => { + it('warns if a path isn not absolute', () => { + const record = { + path: '/parent', + components, + } + const matcher = createRouterMatcher([record], {}) + matcher.resolve( + { path: 'two' }, + { + path: '/parent/one', + name: undefined, + params: {}, + matched: [] as any, + meta: {}, + } + ) + expect('received "two"').toHaveBeenWarned() + }) + + it('matches with nothing', () => { + const record = { path: '/home', name: 'Home', components } + assertRecordMatch( + record, + {}, + { name: 'Home', path: '/home' }, + { + name: 'Home', + params: {}, + path: '/home', + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: undefined, path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: 'UserEdit', path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [], + meta: {}, + } + ) + }) + + it('keep params if not provided', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + {}, + { + name: 'UserEdit', + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('keep params if not provided even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + {}, + { + name: undefined, + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a' }, + path: '/a', + matched: [], + meta: {}, + } + ) + }) + + it('keep optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + {}, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('merges optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { a: 'c' } }, + { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('throws if the current named route does not exists', () => { + const record = { path: '/', components } + const start = { + name: 'home', + params: {}, + path: '/', + matched: [record], + } + // the property should be non enumerable + Object.defineProperty(start, 'matched', { enumerable: false }) + expect( + assertErrorMatch( + record, + { params: { a: 'foo' } }, + { + ...start, + matched: start.matched.map(normalizeRouteRecord), + meta: {}, + } + ) + ).toMatchSnapshot() + }) + + it('avoids records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/articles', + children: [{ path: ':id', components }], + }, + { path: '/articles' } + ) + }) + + it('avoid deeply nested records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/app', + components, + children: [ + { + path: '/articles', + children: [{ path: ':id', components }], + }, + ], + }, + { path: '/articles' } + ) + }) + + it('can reach a named route with children and no component if named', () => { + assertRecordMatch( + { + path: '/articles', + name: 'ArticlesParent', + children: [{ path: ':id', components }], + }, + { name: 'ArticlesParent' }, + { name: 'ArticlesParent', path: '/articles' } + ) + }) + }) + + describe.skip('alias', () => { + it('resolves an alias', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('multiple aliases', () => { + const record = { + path: '/', + alias: ['/home', '/start'], + name: 'Home', + components, + meta: { foo: true }, + } + + assertRecordMatch( + record, + { path: '/' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/start' }, + { + name: 'Home', + path: '/start', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/start', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves the original record by name', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { name: 'Home' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves an alias with children to the alias when using the path', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { path: '/p/one' }, + { + path: '/p/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/p', + children, + components, + aliasOf: expect.objectContaining({ path: '/parent' }), + }, + { + path: '/p/one', + name: 'nested', + components, + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }, + ], + } + ) + }) + + describe('nested aliases', () => { + const children = [ + { + path: 'one', + component, + name: 'nested', + alias: 'o', + children: [ + { path: 'two', alias: 't', name: 'nestednested', component }, + ], + }, + { + path: 'other', + alias: 'otherAlias', + component, + name: 'other', + }, + ] + const record = { + path: '/parent', + name: 'parent', + alias: '/p', + component, + children, + } + + it('resolves the parent as an alias', () => { + assertRecordMatch( + record, + { path: '/p' }, + expect.objectContaining({ + path: '/p', + name: 'parent', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + ], + }) + ) + }) + + describe('multiple children', () => { + // tests concerning the /parent/other path and its aliases + + it('resolves the alias parent', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias child', () => { + assertRecordMatch( + record, + { path: '/parent/otherAlias' }, + expect.objectContaining({ + path: '/parent/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias parent and child', () => { + assertRecordMatch( + record, + { path: '/p/otherAlias' }, + expect.objectContaining({ + path: '/p/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original one with no aliases', () => { + assertRecordMatch( + record, + { path: '/parent/one/two' }, + expect.objectContaining({ + path: '/parent/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/two', + aliasOf: undefined, + }), + ], + }) + ) + }) + + it.todo('resolves when parent is an alias and child has an absolute path') + + it('resolves when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/one/two' }, + expect.objectContaining({ + path: '/p/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves a different child when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves when the first child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/o/two' }, + expect.objectContaining({ + path: '/parent/o/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the second child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/one/t' }, + expect.objectContaining({ + path: '/parent/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the two last children are aliases', () => { + assertRecordMatch( + record, + { path: '/parent/o/t' }, + expect.objectContaining({ + path: '/parent/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when all are aliases', () => { + assertRecordMatch( + record, + { path: '/p/o/t' }, + expect.objectContaining({ + path: '/p/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when first and last are aliases', () => { + assertRecordMatch( + record, + { path: '/p/one/t' }, + expect.objectContaining({ + path: '/p/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original path of the named children of a route with an alias', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { name: 'nested' }, + { + path: '/parent/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/parent', + children, + components, + aliasOf: undefined, + }, + { path: '/parent/one', name: 'nested', components }, + ], + } + ) + }) + }) + + describe.skip('children', () => { + const ChildA = { path: 'a', name: 'child-a', components } + const ChildB = { path: 'b', name: 'child-b', components } + const ChildC = { path: 'c', name: 'child-c', components } + const ChildD = { path: '/absolute', name: 'absolute', components } + const ChildWithParam = { path: ':p', name: 'child-params', components } + const NestedChildWithParam = { + ...ChildWithParam, + name: 'nested-child-params', + } + const NestedChildA = { ...ChildA, name: 'nested-child-a' } + const NestedChildB = { ...ChildB, name: 'nested-child-b' } + const NestedChildC = { ...ChildC, name: 'nested-child-c' } + const Nested = { + path: 'nested', + name: 'nested', + components, + children: [NestedChildA, NestedChildB, NestedChildC], + } + const NestedWithParam = { + path: 'nested/:n', + name: 'nested', + components, + children: [NestedChildWithParam], + } + + it('resolves children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildB, ChildC], + } + assertRecordMatch( + Foo, + { path: '/foo/b' }, + { + name: 'child-b', + path: '/foo/b', + params: {}, + matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], + } + ) + }) + + it('resolves children with empty paths', () => { + const Nested = { path: '', name: 'nested', components } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [Foo as any, { ...Nested, path: `${Foo.path}` }], + } + ) + }) + + it('resolves nested children with empty paths', () => { + const NestedNested = { path: '', name: 'nested', components } + const Nested = { + path: '', + name: 'nested-nested', + components, + children: [NestedNested], + } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}` }, + { ...NestedNested, path: `${Foo.path}` }, + ], + } + ) + }) + + it('resolves nested children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { name: 'nested-child-a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with relative location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + {}, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + }, + { + name: 'nested-child-a', + matched: [], + params: {}, + path: '/foo/nested/a', + meta: {}, + } + ) + }) + + it('resolves nested children with params', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a/b' }, + { + name: 'nested-child-params', + path: '/foo/nested/a/b', + params: { p: 'b', n: 'a' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with params with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { name: 'nested-child-params', params: { p: 'a', n: 'b' } }, + { + name: 'nested-child-params', + path: '/foo/nested/b/a', + params: { p: 'a', n: 'b' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves absolute path children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildD], + } + assertRecordMatch( + Foo, + { path: '/absolute' }, + { + name: 'absolute', + path: '/absolute', + params: {}, + matched: [Foo, ChildD], + } + ) + }) + + it('resolves children with root as the parent', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/nested' }, + { + name: 'nested', + path: '/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/nested` }], + } + ) + }) + + it('resolves children with parent with trailing slash', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/parent/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/parent/nested' }, + { + name: 'nested', + path: '/parent/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/parent/nested` }], + } + ) + }) + }) +}) diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts new file mode 100644 index 000000000..335ddb83d --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from 'vitest' +import { + createCompiledMatcher, + NO_MATCH_LOCATION, + pathEncoded, +} from './resolver' +import { + MatcherPatternParams_Base, + MatcherPatternPath, + MatcherPatternQuery, + MatcherPatternPathStatic, + MatcherPatternPathDynamic, +} from './matcher-pattern' +import { NEW_MatcherRecord } from './resolver' +import { miss } from './matchers/errors' +import { EmptyParams } from './matcher-location' + +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) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + 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}` : ''), +} + +const EMPTY_PATH_ROUTE = { + name: 'no params', + path: EMPTY_PATH_PATTERN_MATCHER, +} 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 NEW_MatcherRecord + +describe('RouterMatcher', () => { + describe('new matchers', () => { + it('static path', () => { + const matcher = createCompiledMatcher([ + { path: new MatcherPatternPathStatic('/') }, + { path: new MatcherPatternPathStatic('/users') }, + ]) + + expect(matcher.resolve('/')).toMatchObject({ + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + }) + + expect(matcher.resolve('/users')).toMatchObject({ + fullPath: '/users', + path: '/users', + params: {}, + query: {}, + hash: '', + }) + }) + + it('dynamic path', () => { + const matcher = createCompiledMatcher([ + { + path: new MatcherPatternPathDynamic<{ id: string }>( + /^\/users\/([^\/]+)$/, + { + id: {}, + }, + ({ id }) => pathEncoded`/users/${id}` + ), + }, + ]) + + expect(matcher.resolve('/users/1')).toMatchObject({ + fullPath: '/users/1', + path: '/users/1', + params: { id: '1' }, + }) + }) + }) + + describe('adding and removing', () => { + it('add static path', () => { + const matcher = createCompiledMatcher() + matcher.addMatcher(EMPTY_PATH_ROUTE) + }) + + it('adds dynamic path', () => { + const matcher = createCompiledMatcher() + matcher.addMatcher(USER_ID_ROUTE) + }) + + it('removes static path', () => { + const matcher = createCompiledMatcher() + matcher.addMatcher(EMPTY_PATH_ROUTE) + matcher.removeMatcher(EMPTY_PATH_ROUTE) + // Add assertions to verify the route was removed + }) + + it('removes dynamic path', () => { + const matcher = createCompiledMatcher() + matcher.addMatcher(USER_ID_ROUTE) + matcher.removeMatcher(USER_ID_ROUTE) + // Add assertions to verify the route was removed + }) + }) + + describe('resolve()', () => { + describe('absolute locations as strings', () => { + it('resolves string locations with no params', () => { + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) + + expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({ + path: '/', + params: {}, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + }) + + it('resolves a not found string', () => { + const matcher = createCompiledMatcher() + expect(matcher.resolve('/bar?q=1#hash')).toEqual({ + ...NO_MATCH_LOCATION, + fullPath: '/bar?q=1#hash', + path: '/bar', + query: { q: '1' }, + hash: '#hash', + matched: [], + }) + }) + + it('resolves string locations with params', () => { + const matcher = createCompiledMatcher([USER_ID_ROUTE]) + + expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({ + path: '/users/1', + params: { id: 1 }, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + expect(matcher.resolve('/users/54?a=a&b=b#h')).toMatchObject({ + path: '/users/54', + params: { id: 54 }, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + }) + + it('resolve string locations with query', () => { + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + }, + ]) + + expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({ + params: { page: 100 }, + path: '/foo', + query: { + page: '100', + b: 'b', + }, + hash: '#h', + }) + }) + + it('resolves string locations with hash', () => { + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) + + expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ + hash: '#bar', + params: { hash: 'bar' }, + path: '/foo', + query: { a: 'a', b: 'b' }, + }) + }) + + it('combines path, query and hash params', () => { + const matcher = createCompiledMatcher([ + { + path: USER_ID_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) + + expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({ + params: { id: 24, page: 100, hash: 'bar' }, + }) + }) + }) + + describe('relative locations as strings', () => { + it('resolves a simple relative location', () => { + const matcher = createCompiledMatcher([ + { path: ANY_PATH_PATTERN_MATCHER }, + ]) + + expect( + matcher.resolve('foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', + }) + expect( + matcher.resolve('../foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/foo', + query: {}, + hash: '', + }) + expect( + matcher.resolve('./foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', + }) + }) + }) + + describe('absolute locations as objects', () => { + it('resolves an object location', () => { + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) + expect(matcher.resolve({ path: '/' })).toMatchObject({ + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + }) + }) + }) + + describe('named locations', () => { + it('resolves named locations with no params', () => { + const matcher = createCompiledMatcher([ + { + name: 'home', + path: EMPTY_PATH_PATTERN_MATCHER, + }, + ]) + + expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({ + name: 'home', + path: '/', + params: {}, + query: {}, + hash: '', + }) + }) + }) + + describe('encoding', () => { + const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) + describe('decodes', () => { + it('handles encoded string path', () => { + expect(matcher.resolve('/%23%2F%3F')).toMatchObject({ + fullPath: '/%23%2F%3F', + path: '/%23%2F%3F', + query: {}, + params: {}, + hash: '', + }) + }) + + it('decodes query from a string', () => { + 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', () => { + expect(matcher.resolve('/foo#%22')).toMatchObject({ + path: '/foo', + fullPath: '/foo#%22', + hash: '#"', + }) + }) + }) + + describe('encodes', () => { + it('encodes the query', () => { + expect( + matcher.resolve({ path: '/foo', query: { foo: '"' } }) + ).toMatchObject({ + fullPath: '/foo?foo=%22', + query: { foo: '"' }, + }) + }) + + it('encodes the hash', () => { + expect(matcher.resolve({ path: '/foo', hash: '#"' })).toMatchObject({ + fullPath: '/foo#%22', + hash: '#"', + }) + }) + }) + }) + }) +}) diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts new file mode 100644 index 000000000..26060c3a6 --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -0,0 +1,66 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { + NEW_LocationResolved, + NEW_MatcherRecordRaw, + NEW_RouterResolver, +} from './resolver' +import { EXPERIMENTAL_RouteRecordNormalized } from '../experimental/router' + +describe('Matcher', () => { + type TMatcherRecordRaw = NEW_MatcherRecordRaw + type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized + + const matcher: NEW_RouterResolver = + {} as any + + describe('matcher.resolve()', () => { + it('resolves absolute string locations', () => { + expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< + NEW_LocationResolved + >() + }) + + it('fails on non absolute location without a currentLocation', () => { + // @ts-expect-error: needs currentLocation + matcher.resolve('foo') + }) + + it('resolves relative locations', () => { + expectTypeOf( + matcher.resolve('foo', {} as NEW_LocationResolved) + ).toEqualTypeOf>() + }) + + it('resolved named locations', () => { + expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf< + NEW_LocationResolved + >() + }) + + it('fails on object relative location without a currentLocation', () => { + // @ts-expect-error: needs currentLocation + matcher.resolve({ params: { id: 1 } }) + }) + + it('resolves object relative locations with a currentLocation', () => { + expectTypeOf( + matcher.resolve( + { params: { id: 1 } }, + {} as NEW_LocationResolved + ) + ).toEqualTypeOf>() + }) + }) + + it('does not allow a name + path', () => { + matcher.resolve({ + // ...({} as NEW_LocationResolved), + name: 'foo', + params: {}, + // @ts-expect-error: name + path + path: '/e', + }) + // @ts-expect-error: name + currentLocation + matcher.resolve({ name: 'a', params: {} }, {} as NEW_LocationResolved) + }) +}) diff --git a/packages/router/src/new-route-resolver/matchers/errors.ts b/packages/router/src/new-route-resolver/matchers/errors.ts new file mode 100644 index 000000000..4ad69cc4c --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/errors.ts @@ -0,0 +1,24 @@ +/** + * NOTE: for these classes to keep the same code we need to tell TS with `"useDefineForClassFields": true` in the `tsconfig.json` + */ + +/** + * Error throw when a matcher miss + */ +export class MatchMiss extends Error { + name = 'MatchMiss' +} + +// NOTE: not sure about having a helper. Using `new MatchMiss(description?)` is good enough +export const miss = () => new MatchMiss() + +/** + * Error throw when a param is invalid when parsing params from path, query, or hash. + */ +export class ParamInvalid extends Error { + name = 'ParamInvalid' + constructor(public param: string) { + super() + } +} +export const invalid = (param: string) => new ParamInvalid(param) diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts new file mode 100644 index 000000000..250efafd9 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -0,0 +1,76 @@ +import { EmptyParams } from '../matcher-location' +import { + MatcherPatternPath, + MatcherPatternQuery, + MatcherPatternParams_Base, +} from '../matcher-pattern' +import { NEW_MatcherRecord } from '../resolver' +import { miss } from './errors' + +export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ + pathMatch: string +}> = { + match(path) { + return { pathMatch: path } + }, + build({ pathMatch }) { + return pathMatch + }, +} + +export const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath = { + match: path => { + if (path !== '/') { + throw miss() + } + return {} + }, + build: () => '/', +} + +export 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}` + }, + } + +export const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = + { + match: query => { + const page = Number(query.page) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + build: params => ({ page: String(params.page) }), + } satisfies MatcherPatternQuery<{ page: number }> + +export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< + string, + { hash: string | null } +> = { + match: hash => ({ hash: hash ? hash.slice(1) : null }), + build: ({ hash }) => (hash ? `#${hash}` : ''), +} + +export const EMPTY_PATH_ROUTE = { + name: 'no params', + path: EMPTY_PATH_PATTERN_MATCHER, +} satisfies NEW_MatcherRecord + +export const USER_ID_ROUTE = { + name: 'user-id', + path: USER_ID_PATH_PATTERN_MATCHER, +} satisfies NEW_MatcherRecord diff --git a/packages/router/src/new-route-resolver/resolver.ts b/packages/router/src/new-route-resolver/resolver.ts new file mode 100644 index 000000000..17ceecf02 --- /dev/null +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -0,0 +1,529 @@ +import { + type LocationQuery, + parseQuery, + normalizeQuery, + stringifyQuery, +} from '../query' +import type { + MatcherPatternHash, + MatcherPatternPath, + MatcherPatternQuery, +} from './matcher-pattern' +import { warn } from '../warning' +import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' +import { parseURL, NEW_stringifyURL } from '../location' +import type { + MatcherLocationAsNamed, + MatcherLocationAsPathAbsolute, + MatcherLocationAsPathRelative, + MatcherLocationAsRelative, + MatcherParamsFormatted, +} from './matcher-location' +import { _RouteRecordProps } from '../typed-routes' + +/** + * Allowed types for a matcher name. + */ +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 addMatcher}. + * - `TMatcherRecord` represents the normalized record type returned by {@link getMatchers}. + */ +export interface NEW_RouterResolver { + /** + * Resolves an absolute location (like `/path/to/somewhere`). + */ + resolve( + absoluteLocation: `/${string}`, + currentLocation?: undefined | NEW_LocationResolved + ): NEW_LocationResolved + + /** + * Resolves a string location relative to another location. A relative location can be `./same-folder`, + * `../parent-folder`, `same-folder`, or even `?page=2`. + */ + resolve( + relativeLocation: string, + 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 + + /** + * 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: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved + + // NOTE: in practice, this overload can cause bugs. It's better to use named locations + + /** + * Resolves a location relative to another location. It reuses existing properties in the `currentLocation` like + * `params`, `query`, and `hash`. + */ + resolve( + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved + + /** + * Add a matcher record. Previously named `addRoute()`. + * @param matcher - The matcher record to add. + * @param parent - The parent matcher record if this is a child. + */ + addMatcher( + matcher: TMatcherRecordRaw, + parent?: TMatcherRecord + ): TMatcherRecord + + /** + * Remove a matcher by its name. Previously named `removeRoute()`. + * @param matcher - The matcher (returned by {@link addMatcher}) to remove. + */ + removeMatcher(matcher: TMatcherRecord): void + + /** + * Remove all matcher records. 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 +} + +/** + * Allowed location objects to be passed to {@link NEW_RouterResolver['resolve']} + */ +export type MatcherLocationRaw = + | `/${string}` + | string + | MatcherLocationAsNamed + | MatcherLocationAsPathAbsolute + | MatcherLocationAsPathRelative + | MatcherLocationAsRelative + +export interface NEW_LocationResolved { + // FIXME: remove `undefined` + name: MatcherName | undefined + // TODO: generics? + params: MatcherParamsFormatted + + fullPath: string + path: string + query: LocationQuery + hash: string + + matched: TMatched[] +} + +export type MatcherPathParamsValue = string | null | string[] +/** + * Params in a string format so they can be encoded/decoded and put into a URL. + */ +export type MatcherPathParams = Record + +export type MatcherQueryParamsValue = string | null | Array +export type MatcherQueryParams = Record + +/** + * Apply a function to all properties in an object. It's used to encode/decode params and queries. + * @internal + */ +export function applyFnToObject( + fn: (v: string | number | null | undefined) => R, + params: MatcherPathParams | LocationQuery | undefined +): Record { + const newParams: Record = {} + + for (const key in params) { + const value = params[key] + newParams[key] = Array.isArray(value) ? value.map(fn) : fn(value) + } + + return newParams +} + +/** + * Decode text using `decodeURIComponent`. Returns the original text if it + * fails. + * + * @param text - string to decode + * @returns decoded string + */ +export function decode(text: string | number): string +export function decode(text: null | undefined): null +export function decode(text: string | number | null | undefined): string | null +export function decode( + text: string | number | null | undefined +): string | null { + if (text == null) return null + try { + return decodeURIComponent('' + text) + } catch (err) { + __DEV__ && warn(`Error decoding "${text}". Using original value`) + } + return '' + text +} +// TODO: just add the null check to the original function in encoding.ts + +interface FnStableNull { + (value: null | undefined): null + (value: string | number): string + // needed for the general case and must be last + (value: string | number | null | undefined): string | null +} + +// function encodeParam(text: null | undefined, encodeSlash?: boolean): null +// function encodeParam(text: string | number, encodeSlash?: boolean): string +// function encodeParam( +// text: string | number | null | undefined, +// encodeSlash?: boolean +// ): string | null +// function encodeParam( +// text: string | number | null | undefined, +// encodeSlash = true +// ): string | null { +// if (text == null) return null +// text = encodePath(text) +// return encodeSlash ? text.replace(SLASH_RE, '%2F') : text +// } + +// @ts-expect-error: overload are not correctly identified +const encodeQueryValue: FnStableNull = + // for ts + value => (value == null ? null : _encodeQueryValue(value)) + +// // @ts-expect-error: overload are not correctly identified +// const encodeQueryKey: FnStableNull = +// // for ts +// value => (value == null ? null : _encodeQueryKey(value)) + +/** + * Common properties for a location that couldn't be matched. This ensures + * having the same name while having a `path`, `query` and `hash` that change. + */ +export const NO_MATCH_LOCATION = { + name: __DEV__ ? Symbol('no-match') : Symbol(), + params: {}, + matched: [], +} satisfies Omit< + NEW_LocationResolved, + 'path' | 'hash' | 'query' | 'fullPath' +> + +// FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc) + +/** + * Experimental new matcher record base type. + * + * @experimental + */ +export interface NEW_MatcherRecordRaw { + path: MatcherPatternPath + query?: MatcherPatternQuery + hash?: MatcherPatternHash + + // NOTE: matchers do not handle `redirect` the redirect option, the router + // does. They can still match the correct record but they will let the router + // retrigger a whole navigation to the new location. + + // TODO: probably as `aliasOf`. Maybe a different format with the path, query and has matchers? + /** + * Aliases for the record. Allows defining extra paths that will behave like a + * copy of the record. Allows having paths shorthands like `/users/:id` and + * `/u/:id`. All `alias` and `path` values must share the same params. + */ + // alias?: string | string[] + + /** + * Name for the route record. Must be unique. Will be set to `Symbol()` if + * not set. + */ + name?: MatcherName + + /** + * Array of nested routes. + */ + children?: NEW_MatcherRecordRaw[] +} + +export interface NEW_MatcherRecordBase { + /** + * Name of the matcher. Unique across all matchers. + */ + name: MatcherName + + path: MatcherPatternPath + query?: MatcherPatternQuery + hash?: MatcherPatternHash + + parent?: T +} + +/** + * Normalized version of a {@link NEW_MatcherRecordRaw} record. + */ +export interface NEW_MatcherRecord + extends NEW_MatcherRecordBase {} + +/** + * Tagged template helper to encode params into a path. Doesn't work with null + */ +export function pathEncoded( + parts: TemplateStringsArray, + ...params: Array +): string { + return parts.reduce((result, part, i) => { + return ( + result + + part + + (Array.isArray(params[i]) + ? params[i].map(encodeParam).join('/') + : encodeParam(params[i])) + ) + }) +} + +// pathEncoded`/users/${1}` +// TODO: +// pathEncoded`/users/${null}/end` + +// const a: RouteRecordRaw = {} as any + +/** + * Build the `matched` array of a record that includes all parent records from the root to the current one. + */ +function buildMatched>(record: T): T[] { + const matched: T[] = [] + let node: T | undefined = record + while (node) { + matched.unshift(node) + node = node.parent + } + return matched +} + +export function createCompiledMatcher< + TMatcherRecord extends NEW_MatcherRecordBase +>( + records: NEW_MatcherRecordRaw[] = [] +): NEW_RouterResolver { + // TODO: we also need an array that has the correct order + const matchers = new Map() + + // TODO: allow custom encode/decode functions + // const encodeParams = applyToParams.bind(null, encodeParam) + // const decodeParams = transformObject.bind(null, String, decode) + // const encodeQuery = transformObject.bind( + // null, + // _encodeQueryKey, + // encodeQueryValue + // ) + // const decodeQuery = transformObject.bind(null, decode, decode) + + // NOTE: because of the overloads, we need to manually type the arguments + type MatcherResolveArgs = + | [ + absoluteLocation: `/${string}`, + currentLocation?: undefined | 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' + if (typeof location === 'string') { + // parseURL handles relative paths + const url = parseURL(parseQuery, location, currentLocation?.path) + + let matcher: TMatcherRecord | undefined + let matched: NEW_LocationResolved['matched'] | undefined + let parsedParams: MatcherParamsFormatted | null | undefined + + for (matcher of matchers.values()) { + // match the path because the path matcher only needs to be matched here + // match the hash because only the deepest child matters + // End up by building up the matched array, (reversed so it goes from + // root to child) and then match and merge all queries + try { + const pathParams = matcher.path.match(url.path) + const hashParams = matcher.hash?.match(url.hash) + matched = buildMatched(matcher) + const queryParams: MatcherQueryParams = Object.assign( + {}, + ...matched.map(matcher => matcher.query?.match(url.query)) + ) + // TODO: test performance + // for (const matcher of matched) { + // Object.assign(queryParams, matcher.query?.match(url.query)) + // } + + parsedParams = { ...pathParams, ...queryParams, ...hashParams } + } catch (e) { + // for debugging tests + // console.log('❌ ERROR matching', e) + } + } + + // No match location + if (!parsedParams || !matched) { + return { + ...url, + ...NO_MATCH_LOCATION, + // already decoded + query: url.query, + hash: url.hash, + } + } + + return { + ...url, + // matcher exists if matched exists + name: matcher!.name, + params: parsedParams, + // already decoded + query: url.query, + hash: url.hash, + matched, + } + // TODO: handle object location { path, query, hash } + } else { + // relative location or by name + if (__DEV__ && location.name == null && currentLocation == null) { + console.warn( + `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: 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 = location.name ?? currentLocation!.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`) + } + + // unencoded params in a formatted form that the user came up with + const params: MatcherParamsFormatted = { + ...currentLocation?.params, + ...location.params, + } + const path = matcher.path.build(params) + const hash = matcher.hash?.build(params) ?? '' + const matched = buildMatched(matcher) + const query = Object.assign( + { + ...currentLocation?.query, + ...normalizeQuery(location.query), + }, + ...matched.map(matcher => matcher.query?.build(params)) + ) + + return { + name, + fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), + path, + query, + hash, + params, + matched, + } + } + } + + function addRoute(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) { + const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) + // FIXME: proper normalization of the record + // @ts-expect-error: we are not properly normalizing the record yet + const normalizedRecord: TMatcherRecord = { + ...record, + name, + parent, + } + matchers.set(name, normalizedRecord) + return normalizedRecord + } + + for (const record of records) { + addRoute(record) + } + + function removeRoute(matcher: TMatcherRecord) { + matchers.delete(matcher.name) + // TODO: delete children and aliases + } + + function clearRoutes() { + matchers.clear() + } + + function getMatchers() { + return Array.from(matchers.values()) + } + + function getMatcher(name: MatcherName) { + return matchers.get(name) + } + + return { + resolve, + + addMatcher: addRoute, + removeMatcher: removeRoute, + clearMatchers: clearRoutes, + getMatcher, + getMatchers, + } +} diff --git a/packages/router/src/query.ts b/packages/router/src/query.ts index 94d914618..55e77c714 100644 --- a/packages/router/src/query.ts +++ b/packages/router/src/query.ts @@ -56,8 +56,7 @@ export function parseQuery(search: string): LocationQuery { // avoid creating an object with an empty key and empty value // because of split('&') if (search === '' || search === '?') return query - const hasLeadingIM = search[0] === '?' - const searchParams = (hasLeadingIM ? search.slice(1) : search).split('&') + const searchParams = (search[0] === '?' ? search.slice(1) : search).split('&') for (let i = 0; i < searchParams.length; ++i) { // pre decode the + into space const searchParam = searchParams[i].replace(PLUS_RE, ' ') @@ -90,9 +89,10 @@ export function parseQuery(search: string): LocationQuery { * @param query - query object to stringify * @returns string version of the query without the leading `?` */ -export function stringifyQuery(query: LocationQueryRaw): string { +export function stringifyQuery(query: LocationQueryRaw | undefined): string { let search = '' for (let key in query) { + // FIXME: we could do search ||= '?' so that the returned value already has the leading ? const value = query[key] key = encodeQueryKey(key) if (value == null) { diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 748a06a32..d8a121fa9 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1,380 +1,277 @@ import { RouteRecordRaw, - Lazy, isRouteLocation, isRouteName, - RouteLocationOptions, MatcherLocationRaw, } from './types' import type { - RouteLocation, RouteLocationRaw, RouteParams, - RouteLocationNormalized, RouteLocationNormalizedLoaded, - NavigationGuardWithThis, - NavigationHookAfter, RouteLocationResolved, - RouteLocationAsRelative, - RouteLocationAsPath, - RouteLocationAsString, RouteRecordNameGeneric, } from './typed-routes' -import { RouterHistory, HistoryState, NavigationType } from './history/common' -import { - ScrollPosition, - getSavedScrollPosition, - getScrollKey, - saveScrollPosition, - computeScrollPosition, - scrollToPosition, - _ScrollPositionNormalized, -} from './scrollBehavior' -import { createRouterMatcher, PathParserOptions } from './matcher' -import { - createRouterError, - ErrorTypes, - NavigationFailure, - NavigationRedirectError, - isNavigationFailure, -} from './errors' -import { applyToParams, isBrowser, assign, noop, isArray } from './utils' -import { useCallbacks } from './utils/callbacks' +import { _ScrollPositionNormalized } from './scrollBehavior' +import { _ErrorListener } from './errors' +import { applyToParams, assign, mergeOptions } from './utils' import { encodeParam, decode, encodeHash } from './encoding' import { normalizeQuery, - parseQuery as originalParseQuery, stringifyQuery as originalStringifyQuery, LocationQuery, + parseQuery, + stringifyQuery, } from './query' -import { shallowRef, Ref, nextTick, App, unref, shallowReactive } from 'vue' -import { RouteRecord, RouteRecordNormalized } from './matcher/types' -import { - parseURL, - stringifyURL, - isSameRouteLocation, - isSameRouteRecord, - START_LOCATION_NORMALIZED, -} from './location' -import { extractComponentsGuards, guardToPromiseFn } from './navigationGuards' +import { RouteRecordNormalized } from './matcher/types' +import { parseURL, stringifyURL } from './location' import { warn } from './warning' -import { RouterLink } from './RouterLink' -import { RouterView } from './RouterView' -import { - routeLocationKey, - routerKey, - routerViewLocationKey, -} from './injectionSymbols' -import { addDevtools } from './devtools' import { _LiteralUnion } from './types/utils' -import { RouteLocationAsRelativeTyped } from './typed-routes/route-location' -import { RouteMap } from './typed-routes/route-map' - -/** - * Internal type to define an ErrorHandler - * - * @param error - error thrown - * @param to - location we were navigating to when the error happened - * @param from - location we were navigating from when the error happened - * @internal - */ -export interface _ErrorListener { - ( - error: any, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded - ): any -} -// resolve, reject arguments of Promise constructor -type OnReadyCallback = [() => void, (reason?: any) => void] - -type Awaitable = T | Promise - -/** - * Type of the `scrollBehavior` option that can be passed to `createRouter`. - */ -export interface RouterScrollBehavior { - /** - * @param to - Route location where we are navigating to - * @param from - Route location where we are navigating from - * @param savedPosition - saved position if it exists, `null` otherwise - */ - ( - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded, - savedPosition: _ScrollPositionNormalized | null - ): Awaitable -} +import { + EXPERIMENTAL_RouteRecordNormalized, + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouterOptions_Base, + EXPERIMENTAL_Router_Base, + _OnReadyCallback, + experimental_createRouter, +} from './experimental/router' +import { createCompiledMatcher } from './new-route-resolver' +import { + NEW_RouterResolver, + NEW_MatcherRecordRaw, +} from './new-route-resolver/resolver' +import { + checkChildMissingNameWithEmptyPath, + normalizeRecordProps, + normalizeRouteRecord, + PathParserOptions, +} from './matcher' +import { PATH_PARSER_OPTIONS_DEFAULTS } from './matcher/pathParserRanker' +import { + createRouteRecordMatcher, + NEW_createRouteRecordMatcher, +} from './matcher/pathMatcher' /** * Options to initialize a {@link Router} instance. */ -export interface RouterOptions extends PathParserOptions { - /** - * History implementation used by the router. Most web applications should use - * `createWebHistory` but it requires the server to be properly configured. - * You can also use a _hash_ based history with `createWebHashHistory` that - * does not require any configuration on the server but isn't handled at all - * by search engines and does poorly on SEO. - * - * @example - * ```js - * createRouter({ - * history: createWebHistory(), - * // other options... - * }) - * ``` - */ - history: RouterHistory +export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base { /** * Initial list of routes that should be added to the router. */ routes: Readonly - /** - * Function to control scrolling when navigating between pages. Can return a - * Promise to delay scrolling. Check {@link ScrollBehavior}. - * - * @example - * ```js - * function scrollBehavior(to, from, savedPosition) { - * // `to` and `from` are both route locations - * // `savedPosition` can be null if there isn't one - * } - * ``` - */ - scrollBehavior?: RouterScrollBehavior - /** - * Custom implementation to parse a query. See its counterpart, - * {@link RouterOptions.stringifyQuery}. - * - * @example - * Let's say you want to use the [qs package](https://github.com/ljharb/qs) - * to parse queries, you can provide both `parseQuery` and `stringifyQuery`: - * ```js - * import qs from 'qs' - * - * createRouter({ - * // other options... - * parseQuery: qs.parse, - * stringifyQuery: qs.stringify, - * }) - * ``` - */ - parseQuery?: typeof originalParseQuery - /** - * Custom implementation to stringify a query object. Should not prepend a leading `?`. - * {@link RouterOptions.parseQuery | parseQuery} counterpart to handle query parsing. - */ - stringifyQuery?: typeof originalStringifyQuery - /** - * Default class applied to active {@link RouterLink}. If none is provided, - * `router-link-active` will be applied. - */ - linkActiveClass?: string - /** - * Default class applied to exact active {@link RouterLink}. If none is provided, - * `router-link-exact-active` will be applied. - */ - linkExactActiveClass?: string - /** - * Default class applied to non-active {@link RouterLink}. If none is provided, - * `router-link-inactive` will be applied. - */ - // linkInactiveClass?: string } /** * Router instance. */ -export interface Router { - /** - * @internal - */ - // readonly history: RouterHistory - /** - * Current {@link RouteLocationNormalized} - */ - readonly currentRoute: Ref +export interface Router + extends EXPERIMENTAL_Router_Base { /** * Original options object passed to create the Router */ readonly options: RouterOptions +} - /** - * Allows turning off the listening of history events. This is a low level api for micro-frontend. - */ - listening: boolean +/* + * Normalizes a RouteRecordRaw. Creates a copy + * + * @param record + * @returns the normalized version + */ +export function NEW_normalizeRouteRecord( + record: RouteRecordRaw & { aliasOf?: RouteRecordNormalized }, + parent?: RouteRecordNormalized +): RouteRecordNormalized { + let { path } = record + // Build up the path for nested routes if the child isn't an absolute + // route. Only add the / delimiter if the child path isn't empty and if the + // parent path doesn't have a trailing slash + if (parent && path[0] !== '/') { + const parentPath = parent.path + const connectingSlash = parentPath[parentPath.length - 1] === '/' ? '' : '/' + path = parentPath + (path && connectingSlash + path) + } - /** - * Add a new {@link 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 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 - /** - * Checks if a route with a given name exists - * - * @param name - Name of the route to check - */ - hasRoute(name: NonNullable): boolean - /** - * Get a full list of all the {@link RouteRecord | route records}. - */ - getRoutes(): RouteRecord[] + const normalized: Omit = { + path, + redirect: record.redirect, + name: record.name, + meta: record.meta || {}, + aliasOf: record.aliasOf, + beforeEnter: record.beforeEnter, + props: normalizeRecordProps(record), + // TODO: normalize children here or outside? + children: record.children || [], + instances: {}, + leaveGuards: new Set(), + updateGuards: new Set(), + enterCallbacks: {}, + // must be declared afterwards + // mods: {}, + components: + 'components' in record + ? record.components || null + : record.component && { default: record.component }, + } - /** - * Delete all routes from the router matcher. - */ - clearRoutes(): void + // mods contain modules and shouldn't be copied, + // logged or anything. It's just used for internal + // advanced use cases like data loaders + Object.defineProperty(normalized, 'mods', { + value: {}, + }) - /** - * Returns the {@link RouteLocation | normalized version} of a - * {@link RouteLocationRaw | route location}. Also includes an `href` property - * that includes any existing `base`. By default, the `currentLocation` used is - * `router.currentRoute` and should only be overridden in advanced use cases. - * - * @param to - Raw route location to resolve - * @param currentLocation - Optional current location to resolve against - */ - resolve( - to: RouteLocationAsRelativeTyped, - // NOTE: This version doesn't work probably because it infers the type too early - // | RouteLocationAsRelative - currentLocation?: RouteLocationNormalizedLoaded - ): RouteLocationResolved - resolve( - // not having the overload produces errors in RouterLink calls to router.resolve() - to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath, - currentLocation?: RouteLocationNormalizedLoaded - ): RouteLocationResolved + return normalized as RouteRecordNormalized +} - /** - * Programmatically navigate to a new URL by pushing an entry in the history - * stack. - * - * @param to - Route location to navigate to - */ - push(to: RouteLocationRaw): Promise +export function compileRouteRecord( + record: RouteRecordRaw, + parent?: RouteRecordNormalized, + originalRecord?: EXPERIMENTAL_RouteRecordNormalized +): EXPERIMENTAL_RouteRecordRaw { + // used later on to remove by name + const isRootAdd = !originalRecord + const options: PathParserOptions = mergeOptions( + PATH_PARSER_OPTIONS_DEFAULTS, + record + ) + const mainNormalizedRecord = NEW_normalizeRouteRecord(record, parent) + const recordMatcher = NEW_createRouteRecordMatcher( + mainNormalizedRecord, + // FIXME: is this needed? + // @ts-expect-error: the parent is the record not the matcher + parent, + options + ) - /** - * Programmatically navigate to a new URL by replacing the current entry in - * the history stack. - * - * @param to - Route location to navigate to - */ - replace(to: RouteLocationRaw): Promise + recordMatcher.record - /** - * Go back in history if possible by calling `history.back()`. Equivalent to - * `router.go(-1)`. - */ - back(): ReturnType - /** - * Go forward in history if possible by calling `history.forward()`. - * Equivalent to `router.go(1)`. - */ - forward(): ReturnType - /** - * Allows you to move forward or backward through the history. Calls - * `history.go()`. - * - * @param delta - The position in the history to which you want to move, - * relative to the current page - */ - go(delta: number): void + if (__DEV__) { + // TODO: + // checkChildMissingNameWithEmptyPath(mainNormalizedRecord, parent) + } + // we might be the child of an alias + // mainNormalizedRecord.aliasOf = originalRecord + // generate an array of records to correctly handle aliases + const normalizedRecords: EXPERIMENTAL_RouteRecordNormalized[] = [ + mainNormalizedRecord, + ] + + if ('alias' in record) { + const aliases = + typeof record.alias === 'string' ? [record.alias] : record.alias! + for (const alias of aliases) { + normalizedRecords.push( + // we need to normalize again to ensure the `mods` property + // being non enumerable + NEW_normalizeRouteRecord( + assign({}, mainNormalizedRecord, { + // this allows us to hold a copy of the `components` option + // so that async components cache is hold on the original record + components: originalRecord + ? originalRecord.record.components + : mainNormalizedRecord.components, + path: alias, + // we might be the child of an alias + aliasOf: originalRecord + ? originalRecord.record + : mainNormalizedRecord, + // the aliases are always of the same kind as the original since they + // are defined on the same record + }) + ) + ) + } + } - /** - * Add a navigation guard that executes before any navigation. Returns a - * function that removes the registered guard. - * - * @param guard - navigation guard to add - */ - beforeEach(guard: NavigationGuardWithThis): () => void - /** - * Add a navigation guard that executes before navigation is about to be - * resolved. At this state all component have been fetched and other - * navigation guards have been successful. Returns a function that removes the - * registered guard. - * - * @param guard - navigation guard to add - * @returns a function that removes the registered guard - * - * @example - * ```js - * router.beforeResolve(to => { - * if (to.meta.requiresAuth && !isAuthenticated) return false - * }) - * ``` - * - */ - beforeResolve(guard: NavigationGuardWithThis): () => void + let matcher: RouteRecordMatcher + let originalMatcher: RouteRecordMatcher | undefined + + for (const normalizedRecord of normalizedRecords) { + const { path } = normalizedRecord + // Build up the path for nested routes if the child isn't an absolute + // route. Only add the / delimiter if the child path isn't empty and if the + // parent path doesn't have a trailing slash + if (parent && path[0] !== '/') { + const parentPath = parent.record.path + const connectingSlash = + parentPath[parentPath.length - 1] === '/' ? '' : '/' + normalizedRecord.path = + parent.record.path + (path && connectingSlash + path) + } - /** - * Add a navigation hook that is executed after every navigation. Returns a - * function that removes the registered hook. - * - * @param guard - navigation hook to add - * @returns a function that removes the registered hook - * - * @example - * ```js - * router.afterEach((to, from, failure) => { - * if (isNavigationFailure(failure)) { - * console.log('failed navigation', failure) - * } - * }) - * ``` - */ - afterEach(guard: NavigationHookAfter): () => void + if (__DEV__ && normalizedRecord.path === '*') { + throw new Error( + 'Catch all routes ("*") must now be defined using a param with a custom regexp.\n' + + 'See more at https://router.vuejs.org/guide/migration/#Removed-star-or-catch-all-routes.' + ) + } - /** - * Adds an error handler that is called every time a non caught error happens - * during navigation. This includes errors thrown synchronously and - * asynchronously, errors returned or passed to `next` in any navigation - * guard, and errors occurred when trying to resolve an async component that - * is required to render a route. - * - * @param handler - error handler to register - */ - onError(handler: _ErrorListener): () => void - /** - * Returns a Promise that resolves when the router has completed the initial - * navigation, which means it has resolved all async enter hooks and async - * components that are associated with the initial route. If the initial - * navigation already happened, the promise resolves immediately. - * - * This is useful in server-side rendering to ensure consistent output on both - * the server and the client. Note that on server side, you need to manually - * push the initial location while on client side, the router automatically - * picks it up from the URL. - */ - isReady(): Promise + // create the object beforehand, so it can be passed to children + matcher = createRouteRecordMatcher(normalizedRecord, parent, options) - /** - * Called automatically by `app.use(router)`. Should not be called manually by - * the user. This will trigger the initial navigation when on client side. - * - * @internal - * @param app - Application that uses the router - */ - install(app: App): void + if (__DEV__ && parent && path[0] === '/') + checkMissingParamsInAbsolutePath(matcher, parent) + + // if we are an alias we must tell the original record that we exist, + // so we can be removed + if (originalRecord) { + originalRecord.alias.push(matcher) + if (__DEV__) { + checkSameParams(originalRecord, matcher) + } + } else { + // otherwise, the first record is the original and others are aliases + originalMatcher = originalMatcher || matcher + if (originalMatcher !== matcher) originalMatcher.alias.push(matcher) + + // remove the route if named and only for the top record (avoid in nested calls) + // this works because the original record is the first one + if (isRootAdd && record.name && !isAliasRecord(matcher)) { + if (__DEV__) { + checkSameNameAsAncestor(record, parent) + } + removeRoute(record.name) + } + } + + // Avoid adding a record that doesn't display anything. This allows passing through records without a component to + // not be reached and pass through the catch all route + if (isMatchable(matcher)) { + insertMatcher(matcher) + } + + if (mainNormalizedRecord.children) { + const children = mainNormalizedRecord.children + for (let i = 0; i < children.length; i++) { + addRoute( + children[i], + matcher, + originalRecord && originalRecord.children[i] + ) + } + } + + // if there was no original record, then the first one was not an alias and all + // other aliases (if any) need to reference this record when adding children + originalRecord = originalRecord || matcher + + // TODO: add normalized records for more flexibility + // if (parent && isAliasRecord(originalRecord)) { + // parent.children.push(originalRecord) + // } + } + + return originalMatcher + ? () => { + // since other matchers are aliases, they should be removed by the original matcher + removeRoute(originalMatcher!) + } + : noop + return { + name: record.name, + children: record.children?.map(child => compileRouteRecord(child, record)), + } } /** @@ -383,29 +280,21 @@ export interface Router { * @param options - {@link RouterOptions} */ export function createRouter(options: RouterOptions): Router { - const matcher = createRouterMatcher(options.routes, options) - const parseQuery = options.parseQuery || originalParseQuery - const stringifyQuery = options.stringifyQuery || originalStringifyQuery - const routerHistory = options.history - if (__DEV__ && !routerHistory) - throw new Error( - 'Provide the "history" option when calling "createRouter()":' + - ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history' - ) - - const beforeGuards = useCallbacks>() - const beforeResolveGuards = useCallbacks>() - const afterGuards = useCallbacks() - const currentRoute = shallowRef( - START_LOCATION_NORMALIZED + const matcher = createCompiledMatcher( + options.routes.map(record => compileRouteRecord(record)) ) - let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED - // leave the scrollRestoration if no scrollBehavior is provided - if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) { - history.scrollRestoration = 'manual' - } + const router = experimental_createRouter({ + matcher, + ...options, + // avoids adding the routes twice + routes: [], + }) + + return router +} +export function _createRouter(options: RouterOptions): Router { const normalizeParams = applyToParams.bind( null, paramValue => '' + paramValue @@ -448,14 +337,6 @@ export function createRouter(options: RouterOptions): Router { } } - function getRoutes() { - return matcher.getRoutes().map(routeMatcher => routeMatcher.record) - } - - function hasRoute(name: NonNullable): boolean { - return !!matcher.getRecordMatcher(name) - } - function resolve( rawLocation: RouteLocationRaw, currentLocation?: RouteLocationNormalizedLoaded @@ -597,762 +478,4 @@ export function createRouter(options: RouterOptions): Router { } ) } - - function locationAsObject( - to: RouteLocationRaw | RouteLocationNormalized - ): Exclude | RouteLocationNormalized { - return typeof to === 'string' - ? parseURL(parseQuery, to, currentRoute.value.path) - : assign({}, to) - } - - function checkCanceledNavigation( - to: RouteLocationNormalized, - from: RouteLocationNormalized - ): NavigationFailure | void { - if (pendingLocation !== to) { - return createRouterError( - ErrorTypes.NAVIGATION_CANCELLED, - { - from, - to, - } - ) - } - } - - function push(to: RouteLocationRaw) { - return pushWithRedirect(to) - } - - function replace(to: RouteLocationRaw) { - return push(assign(locationAsObject(to), { replace: true })) - } - - function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void { - const lastMatched = to.matched[to.matched.length - 1] - if (lastMatched && lastMatched.redirect) { - const { redirect } = lastMatched - let newTargetLocation = - typeof redirect === 'function' ? redirect(to) : redirect - - if (typeof newTargetLocation === 'string') { - newTargetLocation = - newTargetLocation.includes('?') || newTargetLocation.includes('#') - ? (newTargetLocation = locationAsObject(newTargetLocation)) - : // force empty params - { path: newTargetLocation } - // @ts-expect-error: force empty params when a string is passed to let - // the router parse them again - newTargetLocation.params = {} - } - - if ( - __DEV__ && - newTargetLocation.path == null && - !('name' in newTargetLocation) - ) { - warn( - `Invalid redirect found:\n${JSON.stringify( - newTargetLocation, - null, - 2 - )}\n when navigating to "${ - to.fullPath - }". A redirect must contain a name or path. This will break in production.` - ) - throw new Error('Invalid redirect') - } - - return assign( - { - query: to.query, - hash: to.hash, - // avoid transferring params if the redirect has a path - params: newTargetLocation.path != null ? {} : to.params, - }, - newTargetLocation - ) - } - } - - function pushWithRedirect( - to: RouteLocationRaw | RouteLocation, - 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 shouldRedirect = handleRedirectRecord(targetLocation) - - if (shouldRedirect) - return pushWithRedirect( - assign(locationAsObject(shouldRedirect), { - state: - typeof shouldRedirect === 'object' - ? assign({}, data, shouldRedirect.state) - : data, - force, - replace, - }), - // keep original redirectedFrom if it exists - redirectedFrom || targetLocation - ) - - // if it was a redirect we already called `pushWithRedirect` above - const toLocation = targetLocation as RouteLocationNormalized - - toLocation.redirectedFrom = redirectedFrom - let failure: NavigationFailure | void | undefined - - if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) { - failure = createRouterError( - ErrorTypes.NAVIGATION_DUPLICATED, - { to: toLocation, from } - ) - // trigger scroll to allow scrolling to the same anchor - handleScroll( - from, - from, - // this is a push, the only way for it to be triggered from a - // history.listen is with a redirect, which makes it become a push - true, - // This cannot be the first navigation because the initial location - // cannot be manually navigated to - false - ) - } - - return (failure ? Promise.resolve(failure) : navigate(toLocation, from)) - .catch((error: NavigationFailure | NavigationRedirectError) => - isNavigationFailure(error) - ? // navigation redirects still mark the router as ready - isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ? error - : markAsReady(error) // also returns the error - : // reject any unknown error - triggerError(error, toLocation, from) - ) - .then((failure: NavigationFailure | NavigationRedirectError | void) => { - if (failure) { - if ( - isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ) { - if ( - __DEV__ && - // we are redirecting to the same location we were already at - isSameRouteLocation( - stringifyQuery, - resolve(failure.to), - toLocation - ) && - // and we have done it a couple of times - redirectedFrom && - // @ts-expect-error: added only in dev - (redirectedFrom._count = redirectedFrom._count - ? // @ts-expect-error - redirectedFrom._count + 1 - : 1) > 30 - ) { - warn( - `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.` - ) - return Promise.reject( - new Error('Infinite redirect in navigation guard') - ) - } - - 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, - } - ), - // preserve the original redirectedFrom if any - redirectedFrom || toLocation - ) - } - } else { - // if we fail we don't finalize the navigation - failure = finalizeNavigation( - toLocation as RouteLocationNormalizedLoaded, - from, - true, - replace, - data - ) - } - triggerAfterEach( - toLocation as RouteLocationNormalizedLoaded, - from, - failure - ) - return failure - }) - } - - /** - * Helper to reject and skip all navigation guards if a new navigation happened - * @param to - * @param from - */ - function checkCanceledNavigationAndReject( - to: RouteLocationNormalized, - from: RouteLocationNormalized - ): Promise { - const error = checkCanceledNavigation(to, from) - return error ? Promise.reject(error) : Promise.resolve() - } - - function runWithContext(fn: () => T): T { - const app: App | undefined = installedApps.values().next().value - // support Vue < 3.3 - return app && typeof app.runWithContext === 'function' - ? app.runWithContext(fn) - : fn() - } - - // TODO: refactor the whole before guards by internally using router.beforeEach - - function navigate( - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded - ): Promise { - let guards: Lazy[] - - const [leavingRecords, updatingRecords, enteringRecords] = - extractChangingRecords(to, from) - - // all components here have been resolved once because we are leaving - guards = extractComponentsGuards( - leavingRecords.reverse(), - 'beforeRouteLeave', - to, - from - ) - - // leavingRecords is already reversed - for (const record of leavingRecords) { - record.leaveGuards.forEach(guard => { - guards.push(guardToPromiseFn(guard, to, from)) - }) - } - - const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( - null, - to, - from - ) - - guards.push(canceledNavigationCheck) - - // run the queue of per route beforeRouteLeave guards - return ( - runGuardQueue(guards) - .then(() => { - // check global guards beforeEach - guards = [] - for (const guard of beforeGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) - } - guards.push(canceledNavigationCheck) - - return runGuardQueue(guards) - }) - .then(() => { - // check in components beforeRouteUpdate - guards = extractComponentsGuards( - updatingRecords, - 'beforeRouteUpdate', - to, - from - ) - - for (const record of updatingRecords) { - record.updateGuards.forEach(guard => { - guards.push(guardToPromiseFn(guard, to, from)) - }) - } - guards.push(canceledNavigationCheck) - - // run the queue of per route beforeEnter guards - return runGuardQueue(guards) - }) - .then(() => { - // check the route beforeEnter - guards = [] - for (const record of enteringRecords) { - // do not trigger beforeEnter on reused views - if (record.beforeEnter) { - if (isArray(record.beforeEnter)) { - for (const beforeEnter of record.beforeEnter) - guards.push(guardToPromiseFn(beforeEnter, to, from)) - } else { - guards.push(guardToPromiseFn(record.beforeEnter, to, from)) - } - } - } - guards.push(canceledNavigationCheck) - - // run the queue of per route beforeEnter guards - return runGuardQueue(guards) - }) - .then(() => { - // NOTE: at this point to.matched is normalized and does not contain any () => Promise - - // clear existing enterCallbacks, these are added by extractComponentsGuards - to.matched.forEach(record => (record.enterCallbacks = {})) - - // check in-component beforeRouteEnter - guards = extractComponentsGuards( - enteringRecords, - 'beforeRouteEnter', - to, - from, - runWithContext - ) - guards.push(canceledNavigationCheck) - - // run the queue of per route beforeEnter guards - return runGuardQueue(guards) - }) - .then(() => { - // check global guards beforeResolve - guards = [] - for (const guard of beforeResolveGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) - } - guards.push(canceledNavigationCheck) - - return runGuardQueue(guards) - }) - // catch any navigation canceled - .catch(err => - isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED) - ? err - : Promise.reject(err) - ) - ) - } - - function triggerAfterEach( - to: RouteLocationNormalizedLoaded, - from: RouteLocationNormalizedLoaded, - failure?: NavigationFailure | void - ): void { - // navigation is confirmed, call afterGuards - // TODO: wrap with error handlers - afterGuards - .list() - .forEach(guard => runWithContext(() => guard(to, from, failure))) - } - - /** - * - Cleans up any navigation guards - * - Changes the url if necessary - * - Calls the scrollBehavior - */ - function finalizeNavigation( - toLocation: RouteLocationNormalizedLoaded, - from: RouteLocationNormalizedLoaded, - isPush: boolean, - replace?: boolean, - data?: HistoryState - ): NavigationFailure | void { - // a more recent navigation took place - const error = checkCanceledNavigation(toLocation, from) - if (error) return error - - // only consider as push if it's not the first navigation - const isFirstNavigation = from === START_LOCATION_NORMALIZED - const state: Partial | null = !isBrowser ? {} : history.state - - // change URL only if the user did a push/replace and if it's not the initial navigation because - // it's just reflecting the url - if (isPush) { - // on the initial navigation, we want to reuse the scroll position from - // history state if it exists - if (replace || isFirstNavigation) - routerHistory.replace( - toLocation.fullPath, - assign( - { - scroll: isFirstNavigation && state && state.scroll, - }, - data - ) - ) - else routerHistory.push(toLocation.fullPath, data) - } - - // accept current navigation - currentRoute.value = toLocation - handleScroll(toLocation, from, isPush, isFirstNavigation) - - markAsReady() - } - - let removeHistoryListener: undefined | null | (() => void) - // attach listener to history to trigger navigations - function setupListeners() { - // avoid setting up listeners twice due to an invalid first navigation - if (removeHistoryListener) return - removeHistoryListener = routerHistory.listen((to, _from, info) => { - if (!router.listening) return - // cannot be a redirect route because it was in history - const toLocation = resolve(to) as RouteLocationNormalized - - // due to dynamic routing, and to hash history with manual navigation - // (manually changing the url or calling history.hash = '#/somewhere'), - // there could be a redirect record in history - const shouldRedirect = handleRedirectRecord(toLocation) - if (shouldRedirect) { - pushWithRedirect( - assign(shouldRedirect, { replace: true, force: true }), - toLocation - ).catch(noop) - return - } - - pendingLocation = toLocation - const from = currentRoute.value - - // TODO: should be moved to web history? - if (isBrowser) { - saveScrollPosition( - getScrollKey(from.fullPath, info.delta), - computeScrollPosition() - ) - } - - navigate(toLocation, from) - .catch((error: NavigationFailure | NavigationRedirectError) => { - if ( - isNavigationFailure( - error, - ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_CANCELLED - ) - ) { - return error - } - if ( - isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ) { - // Here we could call if (info.delta) routerHistory.go(-info.delta, - // false) but this is bug prone as we have no way to wait the - // navigation to be finished before calling pushWithRedirect. Using - // a setTimeout of 16ms seems to work but there is no guarantee for - // it to work on every browser. So instead we do not restore the - // history entry and trigger a new navigation as requested by the - // navigation guard. - - // the error is already handled by router.push we just want to avoid - // logging the error - pushWithRedirect( - assign(locationAsObject((error as NavigationRedirectError).to), { - force: true, - }), - toLocation - // avoid an uncaught rejection, let push call triggerError - ) - .then(failure => { - // manual change in hash history #916 ending up in the URL not - // changing, but it was changed by the manual url change, so we - // need to manually change it ourselves - if ( - isNavigationFailure( - failure, - ErrorTypes.NAVIGATION_ABORTED | - ErrorTypes.NAVIGATION_DUPLICATED - ) && - !info.delta && - info.type === NavigationType.pop - ) { - routerHistory.go(-1, false) - } - }) - .catch(noop) - // avoid the then branch - return Promise.reject() - } - // do not restore history on unknown direction - if (info.delta) { - routerHistory.go(-info.delta, false) - } - // unrecognized error, transfer to the global handler - return triggerError(error, toLocation, from) - }) - .then((failure: NavigationFailure | void) => { - failure = - failure || - finalizeNavigation( - // after navigation, all matched components are resolved - toLocation as RouteLocationNormalizedLoaded, - from, - false - ) - - // revert the navigation - if (failure) { - if ( - info.delta && - // a new navigation has been triggered, so we do not want to revert, that will change the current history - // entry while a different route is displayed - !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) - ) { - routerHistory.go(-info.delta, false) - } else if ( - info.type === NavigationType.pop && - isNavigationFailure( - failure, - ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_DUPLICATED - ) - ) { - // manual change in hash history #916 - // it's like a push but lacks the information of the direction - routerHistory.go(-1, false) - } - } - - triggerAfterEach( - toLocation as RouteLocationNormalizedLoaded, - from, - failure - ) - }) - // avoid warnings in the console about uncaught rejections, they are logged by triggerErrors - .catch(noop) - }) - } - - // Initialization and Errors - - let readyHandlers = useCallbacks() - let errorListeners = useCallbacks<_ErrorListener>() - let ready: boolean - - /** - * Trigger errorListeners added via onError and throws the error as well - * - * @param error - error to throw - * @param to - location we were navigating to when the error happened - * @param from - location we were navigating from when the error happened - * @returns the error as a rejected promise - */ - function triggerError( - error: any, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded - ): Promise { - markAsReady(error) - const list = errorListeners.list() - if (list.length) { - list.forEach(handler => handler(error, to, from)) - } else { - if (__DEV__) { - warn('uncaught error during route navigation:') - } - console.error(error) - } - // reject the error no matter there were error listeners or not - return Promise.reject(error) - } - - function isReady(): Promise { - if (ready && currentRoute.value !== START_LOCATION_NORMALIZED) - return Promise.resolve() - return new Promise((resolve, reject) => { - readyHandlers.add([resolve, reject]) - }) - } - - /** - * Mark the router as ready, resolving the promised returned by isReady(). Can - * only be called once, otherwise does nothing. - * @param err - optional error - */ - function markAsReady(err: E): E - function markAsReady(): void - function markAsReady(err?: E): E | void { - if (!ready) { - // still not ready if an error happened - ready = !err - setupListeners() - readyHandlers - .list() - .forEach(([resolve, reject]) => (err ? reject(err) : resolve())) - readyHandlers.reset() - } - return err - } - - // Scroll behavior - function handleScroll( - to: RouteLocationNormalizedLoaded, - from: RouteLocationNormalizedLoaded, - isPush: boolean, - isFirstNavigation: boolean - ): // the return is not meant to be used - Promise { - const { scrollBehavior } = options - if (!isBrowser || !scrollBehavior) return Promise.resolve() - - const scrollPosition: _ScrollPositionNormalized | null = - (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) || - ((isFirstNavigation || !isPush) && - (history.state as HistoryState) && - history.state.scroll) || - null - - return nextTick() - .then(() => scrollBehavior(to, from, scrollPosition)) - .then(position => position && scrollToPosition(position)) - .catch(err => triggerError(err, to, from)) - } - - const go = (delta: number) => routerHistory.go(delta) - - let started: boolean | undefined - const installedApps = new Set() - - const router: Router = { - currentRoute, - listening: true, - - addRoute, - removeRoute, - clearRoutes: matcher.clearRoutes, - hasRoute, - getRoutes, - resolve, - options, - - push, - replace, - go, - back: () => go(-1), - forward: () => go(1), - - beforeEach: beforeGuards.add, - beforeResolve: beforeResolveGuards.add, - afterEach: afterGuards.add, - - onError: errorListeners.add, - isReady, - - install(app: App) { - const router = this - app.component('RouterLink', RouterLink) - app.component('RouterView', RouterView) - - app.config.globalProperties.$router = router - Object.defineProperty(app.config.globalProperties, '$route', { - enumerable: true, - get: () => unref(currentRoute), - }) - - // this initial navigation is only necessary on client, on server it doesn't - // make sense because it will create an extra unnecessary navigation and could - // lead to problems - if ( - isBrowser && - // used for the initial navigation client side to avoid pushing - // multiple times when the router is used in multiple apps - !started && - currentRoute.value === START_LOCATION_NORMALIZED - ) { - // see above - started = true - push(routerHistory.location).catch(err => { - if (__DEV__) warn('Unexpected error when starting the router:', err) - }) - } - - const reactiveRoute = {} as RouteLocationNormalizedLoaded - for (const key in START_LOCATION_NORMALIZED) { - Object.defineProperty(reactiveRoute, key, { - get: () => currentRoute.value[key as keyof RouteLocationNormalized], - enumerable: true, - }) - } - - app.provide(routerKey, router) - app.provide(routeLocationKey, shallowReactive(reactiveRoute)) - app.provide(routerViewLocationKey, currentRoute) - - const unmountApp = app.unmount - installedApps.add(app) - app.unmount = function () { - installedApps.delete(app) - // the router is not attached to an app anymore - if (installedApps.size < 1) { - // invalidate the current navigation - pendingLocation = START_LOCATION_NORMALIZED - removeHistoryListener && removeHistoryListener() - removeHistoryListener = null - currentRoute.value = START_LOCATION_NORMALIZED - 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) { - addDevtools(app, router, matcher) - } - }, - } - - // TODO: type this as NavigationGuardReturn or similar instead of any - function runGuardQueue(guards: Lazy[]): Promise { - return guards.reduce( - (promise, guard) => promise.then(() => runWithContext(guard)), - Promise.resolve() - ) - } - - return router -} - -function extractChangingRecords( - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded -) { - const leavingRecords: RouteRecordNormalized[] = [] - const updatingRecords: RouteRecordNormalized[] = [] - const enteringRecords: RouteRecordNormalized[] = [] - - const len = Math.max(from.matched.length, to.matched.length) - for (let i = 0; i < len; i++) { - const recordFrom = from.matched[i] - if (recordFrom) { - if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) - updatingRecords.push(recordFrom) - else leavingRecords.push(recordFrom) - } - const recordTo = to.matched[i] - if (recordTo) { - // the type doesn't matter because we are comparing per reference - if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) { - enteringRecords.push(recordTo) - } - } - } - - return [leavingRecords, updatingRecords, enteringRecords] } diff --git a/packages/router/src/scrollBehavior.ts b/packages/router/src/scrollBehavior.ts index 642556452..8124a9fb0 100644 --- a/packages/router/src/scrollBehavior.ts +++ b/packages/router/src/scrollBehavior.ts @@ -29,6 +29,22 @@ export type _ScrollPositionNormalized = { top: number } +/** + * Type of the `scrollBehavior` option that can be passed to `createRouter`. + */ +export interface RouterScrollBehavior { + /** + * @param to - Route location where we are navigating to + * @param from - Route location where we are navigating from + * @param savedPosition - saved position if it exists, `null` otherwise + */ + ( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded, + savedPosition: _ScrollPositionNormalized | null + ): Awaitable +} + export interface ScrollPositionElement extends ScrollToOptions { /** * A valid CSS selector. Note some characters must be escaped in id selectors (https://mathiasbynens.be/notes/css-escapes). diff --git a/packages/router/src/typed-routes/route-location.ts b/packages/router/src/typed-routes/route-location.ts index 3be525760..c277fd268 100644 --- a/packages/router/src/typed-routes/route-location.ts +++ b/packages/router/src/typed-routes/route-location.ts @@ -2,7 +2,6 @@ import type { RouteLocationOptions, RouteQueryAndHash, _RouteLocationBase, - RouteParamsGeneric, RouteLocationMatched, RouteParamsRawGeneric, } from '../types' @@ -50,7 +49,6 @@ export type RouteLocationTypedList< */ export interface RouteLocationNormalizedGeneric extends _RouteLocationBase { name: RouteRecordNameGeneric - params: RouteParamsGeneric /** * Array of {@link RouteRecordNormalized} */ diff --git a/packages/router/src/types/index.ts b/packages/router/src/types/index.ts index c06643956..b2f221d18 100644 --- a/packages/router/src/types/index.ts +++ b/packages/router/src/types/index.ts @@ -185,7 +185,7 @@ export type RouteComponent = Component | DefineComponent */ export type RawRouteComponent = RouteComponent | Lazy -// TODO: could this be moved to matcher? +// TODO: could this be moved to matcher? YES, it's on the way /** * Internal type for common properties among all kind of {@link RouteRecordRaw}. */ @@ -278,7 +278,9 @@ export interface RouteRecordSingleView extends _RouteRecordBase { } /** - * Route Record defining one single component with a nested view. + * Route Record defining one single component with a nested view. Differently + * from {@link RouteRecordSingleView}, this record has children and allows a + * `redirect` option. */ export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase { /** 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' } diff --git a/packages/router/src/types/utils.ts b/packages/router/src/types/utils.ts index e7d163184..34881aca5 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -6,6 +6,16 @@ export type _LiteralUnion = | LiteralType | (BaseType & Record) +export type IsNull = + // avoid distributive conditional types + [T] extends [null] ? true : false + +export type IsUnknown = unknown extends T // `T` can be `unknown` or `any` + ? IsNull extends false // `any` can be `null`, but `unknown` can't be + ? true + : false + : false + /** * Maybe a promise maybe not * @internal @@ -84,3 +94,5 @@ export type _AlphaNumeric = | '8' | '9' | '_' + +export type Awaitable = T | Promise diff --git a/packages/router/src/utils/index.ts b/packages/router/src/utils/index.ts index b63f9dbb3..c6d622095 100644 --- a/packages/router/src/utils/index.ts +++ b/packages/router/src/utils/index.ts @@ -2,7 +2,6 @@ import { RouteParamsGeneric, RouteComponent, RouteParamsRawGeneric, - RouteParamValueRaw, RawRouteComponent, } from '../types' @@ -45,9 +44,7 @@ export function applyToParams( for (const key in params) { const value = params[key] - newParams[key] = isArray(value) - ? value.map(fn) - : fn(value as Exclude) + newParams[key] = isArray(value) ? value.map(fn) : fn(value) } return newParams @@ -61,3 +58,15 @@ export const noop = () => {} */ export const isArray: (arg: ArrayLike | any) => arg is ReadonlyArray = Array.isArray + +export function mergeOptions( + defaults: T, + partialOptions: Partial +): T { + const options = {} as T + for (const key in defaults) { + options[key] = key in partialOptions ? partialOptions[key]! : defaults[key] + } + + return options +} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json index 41fc6c378..318f5c658 100644 --- a/packages/router/tsconfig.json +++ b/packages/router/tsconfig.json @@ -22,19 +22,14 @@ "noImplicitReturns": true, "strict": true, "skipLibCheck": true, + "useDefineForClassFields": true, // "noUncheckedIndexedAccess": true, "experimentalDecorators": true, "resolveJsonModule": true, "esModuleInterop": true, "removeComments": false, "jsx": "preserve", - "lib": [ - "esnext", - "dom" - ], - "types": [ - "node", - "vite/client" - ] + "lib": ["esnext", "dom"], + "types": ["node", "vite/client"] } } diff --git a/packages/router/vue-router-auto.d.ts b/packages/router/vue-router-auto.d.ts index 56e8a0979..797a70599 100644 --- a/packages/router/vue-router-auto.d.ts +++ b/packages/router/vue-router-auto.d.ts @@ -1,4 +1 @@ -/** - * Extended by unplugin-vue-router to create typed routes. - */ -export interface RouteNamedMap {} +// augmented by unplugin-vue-router pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy