diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 9cf885cc1..654bc3b6d 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -410,6 +410,7 @@ export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord { * Arbitrary data attached to the record. */ meta: RouteMeta + parent?: EXPERIMENTAL_RouteRecordNormalized } function normalizeRouteRecord( 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/router.ts b/packages/router/src/router.ts index 059606db2..d8a121fa9 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1,77 +1,55 @@ import { RouteRecordRaw, - Lazy, isRouteLocation, isRouteName, - RouteLocationOptions, MatcherLocationRaw, } from './types' import type { - RouteLocation, RouteLocationRaw, RouteParams, - RouteLocationNormalized, RouteLocationNormalizedLoaded, - NavigationGuardWithThis, - NavigationHookAfter, RouteLocationResolved, RouteRecordNameGeneric, } from './typed-routes' -import { HistoryState, NavigationType } from './history/common' -import { - getSavedScrollPosition, - getScrollKey, - saveScrollPosition, - computeScrollPosition, - scrollToPosition, - _ScrollPositionNormalized, -} from './scrollBehavior' -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' +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, nextTick, App, unref, shallowReactive } from 'vue' import { RouteRecordNormalized } from './matcher/types' -import { - parseURL, - stringifyURL, - isSameRouteLocation, - START_LOCATION_NORMALIZED, -} from './location' -import { - extractChangingRecords, - extractComponentsGuards, - guardToPromiseFn, -} from './navigationGuards' +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 { + 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. @@ -94,35 +72,229 @@ export interface Router readonly options: RouterOptions } +/* + * 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) + } + + 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 }, + } + + // 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: {}, + }) + + return normalized as RouteRecordNormalized +} + +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 + ) + + recordMatcher.record + + 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 + }) + ) + ) + } + } + + 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) + } + + 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.' + ) + } + + // create the object beforehand, so it can be passed to children + matcher = createRouteRecordMatcher(normalizedRecord, parent, options) + + 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)), + } +} + /** * Creates a Router instance that can be used by a Vue app. * * @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 @@ -165,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 @@ -314,734 +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<_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 }