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..ac44da155 --- /dev/null +++ b/packages/router/src/experimental/router.ts @@ -0,0 +1,1351 @@ +import { + createRouterError, + ErrorTypes, + isNavigationFailure, + NavigationRedirectError, + type _ErrorListener, + type NavigationFailure, +} from '../errors' +import { + nextTick, + shallowReactive, + shallowRef, + unref, + warn, + type App, + type Ref, +} 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 { RouteResolver } from '../new-route-resolver/matcher' +import { + LocationQuery, + normalizeQuery, + 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, + MatcherLocationRaw, + RouteLocationOptions, + type RouteRecordRaw, +} from '../types' +import { useCallbacks } from '../utils/callbacks' +import { + isSameRouteLocation, + parseURL, + START_LOCATION_NORMALIZED, + stringifyURL, +} from '../location' +import { applyToParams, assign, isArray, isBrowser, noop } from '../utils' +import { decode, encodeHash, encodeParam } from '../encoding' +import { + 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 EXPERIMENTAL_RouterOptions_Base.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 +} + +/** + * Options to initialize an experimental {@link EXPERIMENTAL_Router} instance. + * @experimental + */ +export interface EXPERIMENTAL_RouterOptions + 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: RouteResolver +} + +/** + * Router 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: Ref + + /** + * Allows turning off the listening of history events. This is a low level api for micro-frontend. + */ + listening: boolean + + /** + * 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: 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 + extends EXPERIMENTAL_Router_Base { + /** + * Original options object passed to create the Router + */ + readonly options: EXPERIMENTAL_RouterOptions +} + +interface EXPERIMENTAL_RouteRecordRaw {} +interface EXPERIMENTAL_RouteRecord {} + +export function experimental_createRouter( + options: EXPERIMENTAL_RouterOptions< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecord + > +): EXPERIMENTAL_Router { + 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 | RouteRecordRaw, + route?: RouteRecordRaw + ) { + let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined + let record: RouteRecordRaw + if (isRouteName(parentOrRoute)) { + parent = matcher.getMatcher(parentOrRoute) + if (__DEV__ && !parent) { + warn( + `Parent route "${String( + parentOrRoute + )}" not found when adding child route`, + route + ) + } + record = route! + } else { + record = parentOrRoute + } + + return matcher.addRoute(record, parent) + } + + function removeRoute(name: NonNullable) { + const recordMatcher = matcher.getMatcher(name) + if (recordMatcher) { + matcher.removeRoute(recordMatcher) + } else if (__DEV__) { + warn(`Cannot remove non-existent route "${String(name)}"`) + } + } + + function getRoutes() { + return matcher.getMatchers().map(routeMatcher => routeMatcher.record) + } + + 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 + currentLocation = assign({}, currentLocation || currentRoute.value) + if (typeof rawLocation === 'string') { + const locationNormalized = parseURL( + parseQuery, + rawLocation, + currentLocation.path + ) + const matchedRoute = matcher.resolve( + { path: locationNormalized.path }, + currentLocation + ) + + const href = routerHistory.createHref(locationNormalized.fullPath) + if (__DEV__) { + if (href.startsWith('//')) + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + else if (!matchedRoute.matched.length) { + warn(`No match found for location with path "${rawLocation}"`) + } + } + + // locationNormalized is always a new object + return assign(locationNormalized, matchedRoute, { + params: decodeParams(matchedRoute.params), + hash: decode(locationNormalized.hash), + redirectedFrom: undefined, + href, + }) + } + + if (__DEV__ && !isRouteLocation(rawLocation)) { + warn( + `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, + rawLocation + ) + return resolve({}) + } + + let matcherLocation: MatcherLocationRaw + + // path could be relative in object as well + if (rawLocation.path != null) { + if ( + __DEV__ && + 'params' in rawLocation && + !('name' in rawLocation) && + // @ts-expect-error: the type is never + Object.keys(rawLocation.params).length + ) { + warn( + `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.` + ) + } + matcherLocation = assign({}, rawLocation, { + path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path, + }) + } else { + // remove any nullish param + const targetParams = assign({}, rawLocation.params) + for (const key in targetParams) { + if (targetParams[key] == null) { + delete targetParams[key] + } + } + // pass encoded values to the matcher, so it can produce encoded path and fullPath + matcherLocation = assign({}, rawLocation, { + params: encodeParams(targetParams), + }) + // current location params are decoded, we need to encode them in case the + // matcher merges the params + currentLocation.params = encodeParams(currentLocation.params) + } + + const matchedRoute = matcher.resolve(matcherLocation, currentLocation) + const hash = rawLocation.hash || '' + + if (__DEV__ && hash && !hash.startsWith('#')) { + warn( + `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` + ) + } + + // the matcher might have merged current location params, so + // we need to run the decoding again + matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params)) + + const fullPath = stringifyURL( + stringifyQuery, + assign({}, rawLocation, { + hash: encodeHash(hash), + path: matchedRoute.path, + }) + ) + + const href = routerHistory.createHref(fullPath) + if (__DEV__) { + if (href.startsWith('//')) { + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + } else if (!matchedRoute.matched.length) { + warn( + `No match found for location with path "${ + rawLocation.path != null ? rawLocation.path : rawLocation + }"` + ) + } + } + + return assign( + { + fullPath, + // keep the hash encoded so fullPath is effectively path + encodedQuery + + // hash + hash, + query: + // if the user is using a custom query lib like qs, we might have + // nested objects, so we keep the query as is, meaning it can contain + // numbers at `$route.query`, but at the point, the user will have to + // use their own type anyway. + // https://github.com/vuejs/router/issues/328#issuecomment-649481567 + stringifyQuery === originalStringifyQuery + ? normalizeQuery(rawLocation.query) + : ((rawLocation.query || {}) as LocationQuery), + }, + matchedRoute, + { + redirectedFrom: undefined, + href, + } + ) + } + + 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<_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: 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 +} 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/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/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index f508fe113..22fb3e511 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -11,7 +11,6 @@ import { MatcherPatternQuery, MatcherPatternPathStatic, MatcherPatternPathDynamic, - defineParamParser, } from './matcher-pattern' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 748a06a32..059606db2 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -15,14 +15,10 @@ import type { NavigationGuardWithThis, NavigationHookAfter, RouteLocationResolved, - RouteLocationAsRelative, - RouteLocationAsPath, - RouteLocationAsString, RouteRecordNameGeneric, } from './typed-routes' -import { RouterHistory, HistoryState, NavigationType } from './history/common' +import { HistoryState, NavigationType } from './history/common' import { - ScrollPosition, getSavedScrollPosition, getScrollKey, saveScrollPosition, @@ -30,13 +26,14 @@ import { scrollToPosition, _ScrollPositionNormalized, } from './scrollBehavior' -import { createRouterMatcher, PathParserOptions } from './matcher' +import { createRouterMatcher } from './matcher' import { createRouterError, ErrorTypes, NavigationFailure, NavigationRedirectError, isNavigationFailure, + _ErrorListener, } from './errors' import { applyToParams, isBrowser, assign, noop, isArray } from './utils' import { useCallbacks } from './utils/callbacks' @@ -47,16 +44,19 @@ import { stringifyQuery as originalStringifyQuery, LocationQuery, } from './query' -import { shallowRef, Ref, nextTick, App, unref, shallowReactive } from 'vue' -import { RouteRecord, RouteRecordNormalized } from './matcher/types' +import { shallowRef, nextTick, App, unref, shallowReactive } from 'vue' +import { RouteRecordNormalized } from './matcher/types' import { parseURL, stringifyURL, isSameRouteLocation, - isSameRouteRecord, START_LOCATION_NORMALIZED, } from './location' -import { extractComponentsGuards, guardToPromiseFn } from './navigationGuards' +import { + extractChangingRecords, + extractComponentsGuards, + guardToPromiseFn, +} from './navigationGuards' import { warn } from './warning' import { RouterLink } from './RouterLink' import { RouterView } from './RouterView' @@ -67,314 +67,31 @@ import { } 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_RouterOptions_Base, + EXPERIMENTAL_Router_Base, + _OnReadyCallback, +} from './experimental/router' /** * 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 - - /** - * 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[] - - /** - * 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(): 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 - - /** - * 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 } /** @@ -1141,7 +858,7 @@ export function createRouter(options: RouterOptions): Router { // Initialization and Errors - let readyHandlers = useCallbacks() + let readyHandlers = useCallbacks<_OnReadyCallback>() let errorListeners = useCallbacks<_ErrorListener>() let ready: boolean @@ -1328,31 +1045,3 @@ export function createRouter(options: RouterOptions): Router { 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/types/utils.ts b/packages/router/src/types/utils.ts index 2d443f69e..34881aca5 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -94,3 +94,5 @@ export type _AlphaNumeric = | '8' | '9' | '_' + +export type Awaitable = T | Promise