You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2865 lines
109 KiB
2865 lines
109 KiB
/*! |
|
* vue-router v4.2.5 |
|
* (c) 2023 Eduardo San Martin Morote |
|
* @license MIT |
|
*/ |
|
'use strict'; |
|
|
|
var vue = require('vue'); |
|
|
|
const isBrowser = typeof window !== 'undefined'; |
|
|
|
function isESModule(obj) { |
|
return obj.__esModule || obj[Symbol.toStringTag] === 'Module'; |
|
} |
|
const assign = Object.assign; |
|
function applyToParams(fn, params) { |
|
const newParams = {}; |
|
for (const key in params) { |
|
const value = params[key]; |
|
newParams[key] = isArray(value) |
|
? value.map(fn) |
|
: fn(value); |
|
} |
|
return newParams; |
|
} |
|
const noop = () => { }; |
|
/** |
|
* Typesafe alternative to Array.isArray |
|
* https://github.com/microsoft/TypeScript/pull/48228 |
|
*/ |
|
const isArray = Array.isArray; |
|
|
|
const TRAILING_SLASH_RE = /\/$/; |
|
const removeTrailingSlash = (path) => path.replace(TRAILING_SLASH_RE, ''); |
|
/** |
|
* Transforms a URI into a normalized history location |
|
* |
|
* @param parseQuery |
|
* @param location - URI to normalize |
|
* @param currentLocation - current absolute location. Allows resolving relative |
|
* paths. Must start with `/`. Defaults to `/` |
|
* @returns a normalized history location |
|
*/ |
|
function parseURL(parseQuery, location, currentLocation = '/') { |
|
let path, query = {}, searchString = '', hash = ''; |
|
// Could use URL and URLSearchParams but IE 11 doesn't support it |
|
// TODO: move to new URL() |
|
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) { |
|
path = location.slice(0, searchPos); |
|
searchString = location.slice(searchPos + 1, hashPos > -1 ? hashPos : location.length); |
|
query = parseQuery(searchString); |
|
} |
|
if (hashPos > -1) { |
|
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` |
|
return { |
|
fullPath: path + (searchString && '?') + searchString + hash, |
|
path, |
|
query, |
|
hash, |
|
}; |
|
} |
|
/** |
|
* Stringifies a URL object |
|
* |
|
* @param stringifyQuery |
|
* @param location |
|
*/ |
|
function stringifyURL(stringifyQuery, location) { |
|
const query = location.query ? stringifyQuery(location.query) : ''; |
|
return location.path + (query && '?') + query + (location.hash || ''); |
|
} |
|
/** |
|
* Strips off the base from the beginning of a location.pathname in a non-case-sensitive way. |
|
* |
|
* @param pathname - location.pathname |
|
* @param base - base to strip off |
|
*/ |
|
function stripBase(pathname, base) { |
|
// no base or base is not found at the beginning |
|
if (!base || !pathname.toLowerCase().startsWith(base.toLowerCase())) |
|
return pathname; |
|
return pathname.slice(base.length) || '/'; |
|
} |
|
/** |
|
* Checks if two RouteLocation are equal. This means that both locations are |
|
* pointing towards the same {@link RouteRecord} and that all `params`, `query` |
|
* parameters and `hash` are the same |
|
* |
|
* @param stringifyQuery - A function that takes a query object of type LocationQueryRaw and returns a string representation of it. |
|
* @param a - first {@link RouteLocation} |
|
* @param b - second {@link RouteLocation} |
|
*/ |
|
function isSameRouteLocation(stringifyQuery, a, b) { |
|
const aLastIndex = a.matched.length - 1; |
|
const bLastIndex = b.matched.length - 1; |
|
return (aLastIndex > -1 && |
|
aLastIndex === bLastIndex && |
|
isSameRouteRecord(a.matched[aLastIndex], b.matched[bLastIndex]) && |
|
isSameRouteLocationParams(a.params, b.params) && |
|
stringifyQuery(a.query) === stringifyQuery(b.query) && |
|
a.hash === b.hash); |
|
} |
|
/** |
|
* Check if two `RouteRecords` are equal. Takes into account aliases: they are |
|
* considered equal to the `RouteRecord` they are aliasing. |
|
* |
|
* @param a - first {@link RouteRecord} |
|
* @param b - second {@link RouteRecord} |
|
*/ |
|
function isSameRouteRecord(a, b) { |
|
// since the original record has an undefined value for aliasOf |
|
// but all aliases point to the original record, this will always compare |
|
// the original record |
|
return (a.aliasOf || a) === (b.aliasOf || b); |
|
} |
|
function isSameRouteLocationParams(a, b) { |
|
if (Object.keys(a).length !== Object.keys(b).length) |
|
return false; |
|
for (const key in a) { |
|
if (!isSameRouteLocationParamsValue(a[key], b[key])) |
|
return false; |
|
} |
|
return true; |
|
} |
|
function isSameRouteLocationParamsValue(a, b) { |
|
return isArray(a) |
|
? isEquivalentArray(a, b) |
|
: isArray(b) |
|
? isEquivalentArray(b, a) |
|
: a === b; |
|
} |
|
/** |
|
* Check if two arrays are the same or if an array with one single entry is the |
|
* same as another primitive value. Used to check query and parameters |
|
* |
|
* @param a - array of values |
|
* @param b - array of values or a single value |
|
*/ |
|
function isEquivalentArray(a, b) { |
|
return isArray(b) |
|
? a.length === b.length && a.every((value, i) => value === b[i]) |
|
: a.length === 1 && a[0] === b; |
|
} |
|
/** |
|
* Resolves a relative path that starts with `.`. |
|
* |
|
* @param to - path location we are resolving |
|
* @param from - currentLocation.path, should start with `/` |
|
*/ |
|
function resolveRelativePath(to, from) { |
|
if (to.startsWith('/')) |
|
return to; |
|
if (!to) |
|
return from; |
|
const fromSegments = from.split('/'); |
|
const toSegments = to.split('/'); |
|
const lastToSegment = toSegments[toSegments.length - 1]; |
|
// make . and ./ the same (../ === .., ../../ === ../..) |
|
// this is the same behavior as new URL() |
|
if (lastToSegment === '..' || lastToSegment === '.') { |
|
toSegments.push(''); |
|
} |
|
let position = fromSegments.length - 1; |
|
let toPosition; |
|
let segment; |
|
for (toPosition = 0; toPosition < toSegments.length; toPosition++) { |
|
segment = toSegments[toPosition]; |
|
// we stay on the same position |
|
if (segment === '.') |
|
continue; |
|
// go up in the from array |
|
if (segment === '..') { |
|
// we can't go below zero, but we still need to increment toPosition |
|
if (position > 1) |
|
position--; |
|
// continue |
|
} |
|
// we reached a non-relative path, we stop here |
|
else |
|
break; |
|
} |
|
return (fromSegments.slice(0, position).join('/') + |
|
'/' + |
|
toSegments |
|
// ensure we use at least the last element in the toSegments |
|
.slice(toPosition - (toPosition === toSegments.length ? 1 : 0)) |
|
.join('/')); |
|
} |
|
|
|
var NavigationType; |
|
(function (NavigationType) { |
|
NavigationType["pop"] = "pop"; |
|
NavigationType["push"] = "push"; |
|
})(NavigationType || (NavigationType = {})); |
|
var NavigationDirection; |
|
(function (NavigationDirection) { |
|
NavigationDirection["back"] = "back"; |
|
NavigationDirection["forward"] = "forward"; |
|
NavigationDirection["unknown"] = ""; |
|
})(NavigationDirection || (NavigationDirection = {})); |
|
/** |
|
* Starting location for Histories |
|
*/ |
|
const START = ''; |
|
// Generic utils |
|
/** |
|
* Normalizes a base by removing any trailing slash and reading the base tag if |
|
* present. |
|
* |
|
* @param base - base to normalize |
|
*/ |
|
function normalizeBase(base) { |
|
if (!base) { |
|
if (isBrowser) { |
|
// respect <base> tag |
|
const baseEl = document.querySelector('base'); |
|
base = (baseEl && baseEl.getAttribute('href')) || '/'; |
|
// strip full URL origin |
|
base = base.replace(/^\w+:\/\/[^\/]+/, ''); |
|
} |
|
else { |
|
base = '/'; |
|
} |
|
} |
|
// ensure leading slash when it was removed by the regex above avoid leading |
|
// slash with hash because the file could be read from the disk like file:// |
|
// and the leading slash would cause problems |
|
if (base[0] !== '/' && base[0] !== '#') |
|
base = '/' + base; |
|
// remove the trailing slash so all other method can just do `base + fullPath` |
|
// to build an href |
|
return removeTrailingSlash(base); |
|
} |
|
// remove any character before the hash |
|
const BEFORE_HASH_RE = /^[^#]+#/; |
|
function createHref(base, location) { |
|
return base.replace(BEFORE_HASH_RE, '#') + location; |
|
} |
|
|
|
function getElementPosition(el, offset) { |
|
const docRect = document.documentElement.getBoundingClientRect(); |
|
const elRect = el.getBoundingClientRect(); |
|
return { |
|
behavior: offset.behavior, |
|
left: elRect.left - docRect.left - (offset.left || 0), |
|
top: elRect.top - docRect.top - (offset.top || 0), |
|
}; |
|
} |
|
const computeScrollPosition = () => ({ |
|
left: window.pageXOffset, |
|
top: window.pageYOffset, |
|
}); |
|
function scrollToPosition(position) { |
|
let scrollToOptions; |
|
if ('el' in position) { |
|
const positionEl = position.el; |
|
const isIdSelector = typeof positionEl === 'string' && positionEl.startsWith('#'); |
|
const el = typeof positionEl === 'string' |
|
? isIdSelector |
|
? document.getElementById(positionEl.slice(1)) |
|
: document.querySelector(positionEl) |
|
: positionEl; |
|
if (!el) { |
|
return; |
|
} |
|
scrollToOptions = getElementPosition(el, position); |
|
} |
|
else { |
|
scrollToOptions = position; |
|
} |
|
if ('scrollBehavior' in document.documentElement.style) |
|
window.scrollTo(scrollToOptions); |
|
else { |
|
window.scrollTo(scrollToOptions.left != null ? scrollToOptions.left : window.pageXOffset, scrollToOptions.top != null ? scrollToOptions.top : window.pageYOffset); |
|
} |
|
} |
|
function getScrollKey(path, delta) { |
|
const position = history.state ? history.state.position - delta : -1; |
|
return position + path; |
|
} |
|
const scrollPositions = new Map(); |
|
function saveScrollPosition(key, scrollPosition) { |
|
scrollPositions.set(key, scrollPosition); |
|
} |
|
function getSavedScrollPosition(key) { |
|
const scroll = scrollPositions.get(key); |
|
// consume it so it's not used again |
|
scrollPositions.delete(key); |
|
return scroll; |
|
} |
|
// TODO: RFC about how to save scroll position |
|
/** |
|
* ScrollBehavior instance used by the router to compute and restore the scroll |
|
* position when navigating. |
|
*/ |
|
// export interface ScrollHandler<ScrollPositionEntry extends HistoryStateValue, ScrollPosition extends ScrollPositionEntry> { |
|
// // returns a scroll position that can be saved in history |
|
// compute(): ScrollPositionEntry |
|
// // can take an extended ScrollPositionEntry |
|
// scroll(position: ScrollPosition): void |
|
// } |
|
// export const scrollHandler: ScrollHandler<ScrollPosition> = { |
|
// compute: computeScroll, |
|
// scroll: scrollToPosition, |
|
// } |
|
|
|
let createBaseLocation = () => location.protocol + '//' + location.host; |
|
/** |
|
* Creates a normalized history location from a window.location object |
|
* @param base - The base path |
|
* @param location - The window.location object |
|
*/ |
|
function createCurrentLocation(base, location) { |
|
const { pathname, search, hash } = location; |
|
// allows hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end |
|
const hashPos = base.indexOf('#'); |
|
if (hashPos > -1) { |
|
let slicePos = hash.includes(base.slice(hashPos)) |
|
? base.slice(hashPos).length |
|
: 1; |
|
let pathFromHash = hash.slice(slicePos); |
|
// prepend the starting slash to hash so the url starts with /# |
|
if (pathFromHash[0] !== '/') |
|
pathFromHash = '/' + pathFromHash; |
|
return stripBase(pathFromHash, ''); |
|
} |
|
const path = stripBase(pathname, base); |
|
return path + search + hash; |
|
} |
|
function useHistoryListeners(base, historyState, currentLocation, replace) { |
|
let listeners = []; |
|
let teardowns = []; |
|
// TODO: should it be a stack? a Dict. Check if the popstate listener |
|
// can trigger twice |
|
let pauseState = null; |
|
const popStateHandler = ({ state, }) => { |
|
const to = createCurrentLocation(base, location); |
|
const from = currentLocation.value; |
|
const fromState = historyState.value; |
|
let delta = 0; |
|
if (state) { |
|
currentLocation.value = to; |
|
historyState.value = state; |
|
// ignore the popstate and reset the pauseState |
|
if (pauseState && pauseState === from) { |
|
pauseState = null; |
|
return; |
|
} |
|
delta = fromState ? state.position - fromState.position : 0; |
|
} |
|
else { |
|
replace(to); |
|
} |
|
// Here we could also revert the navigation by calling history.go(-delta) |
|
// this listener will have to be adapted to not trigger again and to wait for the url |
|
// to be updated before triggering the listeners. Some kind of validation function would also |
|
// need to be passed to the listeners so the navigation can be accepted |
|
// call all listeners |
|
listeners.forEach(listener => { |
|
listener(currentLocation.value, from, { |
|
delta, |
|
type: NavigationType.pop, |
|
direction: delta |
|
? delta > 0 |
|
? NavigationDirection.forward |
|
: NavigationDirection.back |
|
: NavigationDirection.unknown, |
|
}); |
|
}); |
|
}; |
|
function pauseListeners() { |
|
pauseState = currentLocation.value; |
|
} |
|
function listen(callback) { |
|
// set up the listener and prepare teardown callbacks |
|
listeners.push(callback); |
|
const teardown = () => { |
|
const index = listeners.indexOf(callback); |
|
if (index > -1) |
|
listeners.splice(index, 1); |
|
}; |
|
teardowns.push(teardown); |
|
return teardown; |
|
} |
|
function beforeUnloadListener() { |
|
const { history } = window; |
|
if (!history.state) |
|
return; |
|
history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), ''); |
|
} |
|
function destroy() { |
|
for (const teardown of teardowns) |
|
teardown(); |
|
teardowns = []; |
|
window.removeEventListener('popstate', popStateHandler); |
|
window.removeEventListener('beforeunload', beforeUnloadListener); |
|
} |
|
// set up the listeners and prepare teardown callbacks |
|
window.addEventListener('popstate', popStateHandler); |
|
// TODO: could we use 'pagehide' or 'visibilitychange' instead? |
|
// https://developer.chrome.com/blog/page-lifecycle-api/ |
|
window.addEventListener('beforeunload', beforeUnloadListener, { |
|
passive: true, |
|
}); |
|
return { |
|
pauseListeners, |
|
listen, |
|
destroy, |
|
}; |
|
} |
|
/** |
|
* Creates a state object |
|
*/ |
|
function buildState(back, current, forward, replaced = false, computeScroll = false) { |
|
return { |
|
back, |
|
current, |
|
forward, |
|
replaced, |
|
position: window.history.length, |
|
scroll: computeScroll ? computeScrollPosition() : null, |
|
}; |
|
} |
|
function useHistoryStateNavigation(base) { |
|
const { history, location } = window; |
|
// private variables |
|
const currentLocation = { |
|
value: createCurrentLocation(base, location), |
|
}; |
|
const historyState = { value: history.state }; |
|
// build current history entry as this is a fresh navigation |
|
if (!historyState.value) { |
|
changeLocation(currentLocation.value, { |
|
back: null, |
|
current: currentLocation.value, |
|
forward: null, |
|
// the length is off by one, we need to decrease it |
|
position: history.length - 1, |
|
replaced: true, |
|
// don't add a scroll as the user may have an anchor, and we want |
|
// scrollBehavior to be triggered without a saved position |
|
scroll: null, |
|
}, true); |
|
} |
|
function changeLocation(to, state, replace) { |
|
/** |
|
* if a base tag is provided, and we are on a normal domain, we have to |
|
* respect the provided `base` attribute because pushState() will use it and |
|
* potentially erase anything before the `#` like at |
|
* https://github.com/vuejs/router/issues/685 where a base of |
|
* `/folder/#` but a base of `/` would erase the `/folder/` section. If |
|
* there is no host, the `<base>` tag makes no sense and if there isn't a |
|
* base tag we can just use everything after the `#`. |
|
*/ |
|
const hashIndex = base.indexOf('#'); |
|
const url = hashIndex > -1 |
|
? (location.host && document.querySelector('base') |
|
? base |
|
: base.slice(hashIndex)) + to |
|
: createBaseLocation() + base + to; |
|
try { |
|
// BROWSER QUIRK |
|
// NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds |
|
history[replace ? 'replaceState' : 'pushState'](state, '', url); |
|
historyState.value = state; |
|
} |
|
catch (err) { |
|
{ |
|
console.error(err); |
|
} |
|
// Force the navigation, this also resets the call count |
|
location[replace ? 'replace' : 'assign'](url); |
|
} |
|
} |
|
function replace(to, data) { |
|
const state = assign({}, history.state, buildState(historyState.value.back, |
|
// keep back and forward entries but override current position |
|
to, historyState.value.forward, true), data, { position: historyState.value.position }); |
|
changeLocation(to, state, true); |
|
currentLocation.value = to; |
|
} |
|
function push(to, data) { |
|
// Add to current entry the information of where we are going |
|
// as well as saving the current position |
|
const currentState = assign({}, |
|
// use current history state to gracefully handle a wrong call to |
|
// history.replaceState |
|
// https://github.com/vuejs/router/issues/366 |
|
historyState.value, history.state, { |
|
forward: to, |
|
scroll: computeScrollPosition(), |
|
}); |
|
changeLocation(currentState.current, currentState, true); |
|
const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data); |
|
changeLocation(to, state, false); |
|
currentLocation.value = to; |
|
} |
|
return { |
|
location: currentLocation, |
|
state: historyState, |
|
push, |
|
replace, |
|
}; |
|
} |
|
/** |
|
* Creates an HTML5 history. Most common history for single page applications. |
|
* |
|
* @param base - |
|
*/ |
|
function createWebHistory(base) { |
|
base = normalizeBase(base); |
|
const historyNavigation = useHistoryStateNavigation(base); |
|
const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace); |
|
function go(delta, triggerListeners = true) { |
|
if (!triggerListeners) |
|
historyListeners.pauseListeners(); |
|
history.go(delta); |
|
} |
|
const routerHistory = assign({ |
|
// it's overridden right after |
|
location: '', |
|
base, |
|
go, |
|
createHref: createHref.bind(null, base), |
|
}, historyNavigation, historyListeners); |
|
Object.defineProperty(routerHistory, 'location', { |
|
enumerable: true, |
|
get: () => historyNavigation.location.value, |
|
}); |
|
Object.defineProperty(routerHistory, 'state', { |
|
enumerable: true, |
|
get: () => historyNavigation.state.value, |
|
}); |
|
return routerHistory; |
|
} |
|
|
|
/** |
|
* Creates an in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere. |
|
* It's up to the user to replace that location with the starter location by either calling `router.push` or `router.replace`. |
|
* |
|
* @param base - Base applied to all urls, defaults to '/' |
|
* @returns a history object that can be passed to the router constructor |
|
*/ |
|
function createMemoryHistory(base = '') { |
|
let listeners = []; |
|
let queue = [START]; |
|
let position = 0; |
|
base = normalizeBase(base); |
|
function setLocation(location) { |
|
position++; |
|
if (position !== queue.length) { |
|
// we are in the middle, we remove everything from here in the queue |
|
queue.splice(position); |
|
} |
|
queue.push(location); |
|
} |
|
function triggerListeners(to, from, { direction, delta }) { |
|
const info = { |
|
direction, |
|
delta, |
|
type: NavigationType.pop, |
|
}; |
|
for (const callback of listeners) { |
|
callback(to, from, info); |
|
} |
|
} |
|
const routerHistory = { |
|
// rewritten by Object.defineProperty |
|
location: START, |
|
// TODO: should be kept in queue |
|
state: {}, |
|
base, |
|
createHref: createHref.bind(null, base), |
|
replace(to) { |
|
// remove current entry and decrement position |
|
queue.splice(position--, 1); |
|
setLocation(to); |
|
}, |
|
push(to, data) { |
|
setLocation(to); |
|
}, |
|
listen(callback) { |
|
listeners.push(callback); |
|
return () => { |
|
const index = listeners.indexOf(callback); |
|
if (index > -1) |
|
listeners.splice(index, 1); |
|
}; |
|
}, |
|
destroy() { |
|
listeners = []; |
|
queue = [START]; |
|
position = 0; |
|
}, |
|
go(delta, shouldTrigger = true) { |
|
const from = this.location; |
|
const direction = |
|
// we are considering delta === 0 going forward, but in abstract mode |
|
// using 0 for the delta doesn't make sense like it does in html5 where |
|
// it reloads the page |
|
delta < 0 ? NavigationDirection.back : NavigationDirection.forward; |
|
position = Math.max(0, Math.min(position + delta, queue.length - 1)); |
|
if (shouldTrigger) { |
|
triggerListeners(this.location, from, { |
|
direction, |
|
delta, |
|
}); |
|
} |
|
}, |
|
}; |
|
Object.defineProperty(routerHistory, 'location', { |
|
enumerable: true, |
|
get: () => queue[position], |
|
}); |
|
return routerHistory; |
|
} |
|
|
|
/** |
|
* Creates a hash history. Useful for web applications with no host (e.g. `file://`) or when configuring a server to |
|
* handle any URL is not possible. |
|
* |
|
* @param base - optional base to provide. Defaults to `location.pathname + location.search` If there is a `<base>` tag |
|
* in the `head`, its value will be ignored in favor of this parameter **but note it affects all the history.pushState() |
|
* calls**, meaning that if you use a `<base>` tag, it's `href` value **has to match this parameter** (ignoring anything |
|
* after the `#`). |
|
* |
|
* @example |
|
* ```js |
|
* // at https://example.com/folder |
|
* createWebHashHistory() // gives a url of `https://example.com/folder#` |
|
* createWebHashHistory('/folder/') // gives a url of `https://example.com/folder/#` |
|
* // if the `#` is provided in the base, it won't be added by `createWebHashHistory` |
|
* createWebHashHistory('/folder/#/app/') // gives a url of `https://example.com/folder/#/app/` |
|
* // you should avoid doing this because it changes the original url and breaks copying urls |
|
* createWebHashHistory('/other-folder/') // gives a url of `https://example.com/other-folder/#` |
|
* |
|
* // at file:///usr/etc/folder/index.html |
|
* // for locations with no `host`, the base is ignored |
|
* createWebHashHistory('/iAmIgnored') // gives a url of `file:///usr/etc/folder/index.html#` |
|
* ``` |
|
*/ |
|
function createWebHashHistory(base) { |
|
// Make sure this implementation is fine in terms of encoding, specially for IE11 |
|
// for `file://`, directly use the pathname and ignore the base |
|
// location.pathname contains an initial `/` even at the root: `https://example.com` |
|
base = location.host ? base || location.pathname + location.search : ''; |
|
// allow the user to provide a `#` in the middle: `/base/#/app` |
|
if (!base.includes('#')) |
|
base += '#'; |
|
return createWebHistory(base); |
|
} |
|
|
|
function isRouteLocation(route) { |
|
return typeof route === 'string' || (route && typeof route === 'object'); |
|
} |
|
function isRouteName(name) { |
|
return typeof name === 'string' || typeof name === 'symbol'; |
|
} |
|
|
|
/** |
|
* Initial route location where the router is. Can be used in navigation guards |
|
* to differentiate the initial navigation. |
|
* |
|
* @example |
|
* ```js |
|
* import { START_LOCATION } from 'vue-router' |
|
* |
|
* router.beforeEach((to, from) => { |
|
* if (from === START_LOCATION) { |
|
* // initial navigation |
|
* } |
|
* }) |
|
* ``` |
|
*/ |
|
const START_LOCATION_NORMALIZED = { |
|
path: '/', |
|
name: undefined, |
|
params: {}, |
|
query: {}, |
|
hash: '', |
|
fullPath: '/', |
|
matched: [], |
|
meta: {}, |
|
redirectedFrom: undefined, |
|
}; |
|
|
|
const NavigationFailureSymbol = Symbol(''); |
|
/** |
|
* Enumeration with all possible types for navigation failures. Can be passed to |
|
* {@link isNavigationFailure} to check for specific failures. |
|
*/ |
|
exports.NavigationFailureType = void 0; |
|
(function (NavigationFailureType) { |
|
/** |
|
* An aborted navigation is a navigation that failed because a navigation |
|
* guard returned `false` or called `next(false)` |
|
*/ |
|
NavigationFailureType[NavigationFailureType["aborted"] = 4] = "aborted"; |
|
/** |
|
* A cancelled navigation is a navigation that failed because a more recent |
|
* navigation finished started (not necessarily finished). |
|
*/ |
|
NavigationFailureType[NavigationFailureType["cancelled"] = 8] = "cancelled"; |
|
/** |
|
* A duplicated navigation is a navigation that failed because it was |
|
* initiated while already being at the exact same location. |
|
*/ |
|
NavigationFailureType[NavigationFailureType["duplicated"] = 16] = "duplicated"; |
|
})(exports.NavigationFailureType || (exports.NavigationFailureType = {})); |
|
// DEV only debug messages |
|
const ErrorTypeMessages = { |
|
[1 /* ErrorTypes.MATCHER_NOT_FOUND */]({ location, currentLocation }) { |
|
return `No match for\n ${JSON.stringify(location)}${currentLocation |
|
? '\nwhile being at\n' + JSON.stringify(currentLocation) |
|
: ''}`; |
|
}, |
|
[2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */]({ from, to, }) { |
|
return `Redirected from "${from.fullPath}" to "${stringifyRoute(to)}" via a navigation guard.`; |
|
}, |
|
[4 /* ErrorTypes.NAVIGATION_ABORTED */]({ from, to }) { |
|
return `Navigation aborted from "${from.fullPath}" to "${to.fullPath}" via a navigation guard.`; |
|
}, |
|
[8 /* ErrorTypes.NAVIGATION_CANCELLED */]({ from, to }) { |
|
return `Navigation cancelled from "${from.fullPath}" to "${to.fullPath}" with a new navigation.`; |
|
}, |
|
[16 /* ErrorTypes.NAVIGATION_DUPLICATED */]({ from, to }) { |
|
return `Avoided redundant navigation to current location: "${from.fullPath}".`; |
|
}, |
|
}; |
|
function createRouterError(type, params) { |
|
// keep full error messages in cjs versions |
|
{ |
|
return assign(new Error(ErrorTypeMessages[type](params)), { |
|
type, |
|
[NavigationFailureSymbol]: true, |
|
}, params); |
|
} |
|
} |
|
function isNavigationFailure(error, type) { |
|
return (error instanceof Error && |
|
NavigationFailureSymbol in error && |
|
(type == null || !!(error.type & type))); |
|
} |
|
const propertiesToLog = ['params', 'query', 'hash']; |
|
function stringifyRoute(to) { |
|
if (typeof to === 'string') |
|
return to; |
|
if ('path' in to) |
|
return to.path; |
|
const location = {}; |
|
for (const key of propertiesToLog) { |
|
if (key in to) |
|
location[key] = to[key]; |
|
} |
|
return JSON.stringify(location, null, 2); |
|
} |
|
|
|
// default pattern for a param: non-greedy everything but / |
|
const BASE_PARAM_PATTERN = '[^/]+?'; |
|
const BASE_PATH_PARSER_OPTIONS = { |
|
sensitive: false, |
|
strict: false, |
|
start: true, |
|
end: true, |
|
}; |
|
// Special Regex characters that must be escaped in static tokens |
|
const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g; |
|
/** |
|
* Creates a path parser from an array of Segments (a segment is an array of Tokens) |
|
* |
|
* @param segments - array of segments returned by tokenizePath |
|
* @param extraOptions - optional options for the regexp |
|
* @returns a PathParser |
|
*/ |
|
function tokensToParser(segments, extraOptions) { |
|
const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions); |
|
// the amount of scores is the same as the length of segments except for the root segment "/" |
|
const score = []; |
|
// the regexp as a string |
|
let pattern = options.start ? '^' : ''; |
|
// extracted keys |
|
const keys = []; |
|
for (const segment of segments) { |
|
// the root segment needs special treatment |
|
const segmentScores = segment.length ? [] : [90 /* PathScore.Root */]; |
|
// allow trailing slash |
|
if (options.strict && !segment.length) |
|
pattern += '/'; |
|
for (let tokenIndex = 0; tokenIndex < segment.length; tokenIndex++) { |
|
const token = segment[tokenIndex]; |
|
// resets the score if we are inside a sub-segment /:a-other-:b |
|
let subSegmentScore = 40 /* PathScore.Segment */ + |
|
(options.sensitive ? 0.25 /* PathScore.BonusCaseSensitive */ : 0); |
|
if (token.type === 0 /* TokenType.Static */) { |
|
// prepend the slash if we are starting a new segment |
|
if (!tokenIndex) |
|
pattern += '/'; |
|
pattern += token.value.replace(REGEX_CHARS_RE, '\\$&'); |
|
subSegmentScore += 40 /* PathScore.Static */; |
|
} |
|
else if (token.type === 1 /* TokenType.Param */) { |
|
const { value, repeatable, optional, regexp } = token; |
|
keys.push({ |
|
name: value, |
|
repeatable, |
|
optional, |
|
}); |
|
const re = regexp ? regexp : BASE_PARAM_PATTERN; |
|
// the user provided a custom regexp /:id(\\d+) |
|
if (re !== BASE_PARAM_PATTERN) { |
|
subSegmentScore += 10 /* PathScore.BonusCustomRegExp */; |
|
// make sure the regexp is valid before using it |
|
try { |
|
new RegExp(`(${re})`); |
|
} |
|
catch (err) { |
|
throw new Error(`Invalid custom RegExp for param "${value}" (${re}): ` + |
|
err.message); |
|
} |
|
} |
|
// when we repeat we must take care of the repeating leading slash |
|
let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})`; |
|
// prepend the slash if we are starting a new segment |
|
if (!tokenIndex) |
|
subPattern = |
|
// avoid an optional / if there are more segments e.g. /:p?-static |
|
// or /:p?-:p2 |
|
optional && segment.length < 2 |
|
? `(?:/${subPattern})` |
|
: '/' + subPattern; |
|
if (optional) |
|
subPattern += '?'; |
|
pattern += subPattern; |
|
subSegmentScore += 20 /* PathScore.Dynamic */; |
|
if (optional) |
|
subSegmentScore += -8 /* PathScore.BonusOptional */; |
|
if (repeatable) |
|
subSegmentScore += -20 /* PathScore.BonusRepeatable */; |
|
if (re === '.*') |
|
subSegmentScore += -50 /* PathScore.BonusWildcard */; |
|
} |
|
segmentScores.push(subSegmentScore); |
|
} |
|
// an empty array like /home/ -> [[{home}], []] |
|
// if (!segment.length) pattern += '/' |
|
score.push(segmentScores); |
|
} |
|
// only apply the strict bonus to the last score |
|
if (options.strict && options.end) { |
|
const i = score.length - 1; |
|
score[i][score[i].length - 1] += 0.7000000000000001 /* PathScore.BonusStrict */; |
|
} |
|
// TODO: dev only warn double trailing slash |
|
if (!options.strict) |
|
pattern += '/?'; |
|
if (options.end) |
|
pattern += '$'; |
|
// allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else |
|
else if (options.strict) |
|
pattern += '(?:/|$)'; |
|
const re = new RegExp(pattern, options.sensitive ? '' : 'i'); |
|
function parse(path) { |
|
const match = path.match(re); |
|
const params = {}; |
|
if (!match) |
|
return null; |
|
for (let i = 1; i < match.length; i++) { |
|
const value = match[i] || ''; |
|
const key = keys[i - 1]; |
|
params[key.name] = value && key.repeatable ? value.split('/') : value; |
|
} |
|
return params; |
|
} |
|
function stringify(params) { |
|
let path = ''; |
|
// for optional parameters to allow to be empty |
|
let avoidDuplicatedSlash = false; |
|
for (const segment of segments) { |
|
if (!avoidDuplicatedSlash || !path.endsWith('/')) |
|
path += '/'; |
|
avoidDuplicatedSlash = false; |
|
for (const token of segment) { |
|
if (token.type === 0 /* TokenType.Static */) { |
|
path += token.value; |
|
} |
|
else if (token.type === 1 /* TokenType.Param */) { |
|
const { value, repeatable, optional } = token; |
|
const param = value in params ? params[value] : ''; |
|
if (isArray(param) && !repeatable) { |
|
throw new Error(`Provided param "${value}" is an array but it is not repeatable (* or + modifiers)`); |
|
} |
|
const text = isArray(param) |
|
? param.join('/') |
|
: param; |
|
if (!text) { |
|
if (optional) { |
|
// if we have more than one optional param like /:a?-static we don't need to care about the optional param |
|
if (segment.length < 2) { |
|
// remove the last slash as we could be at the end |
|
if (path.endsWith('/')) |
|
path = path.slice(0, -1); |
|
// do not append a slash on the next iteration |
|
else |
|
avoidDuplicatedSlash = true; |
|
} |
|
} |
|
else |
|
throw new Error(`Missing required param "${value}"`); |
|
} |
|
path += text; |
|
} |
|
} |
|
} |
|
// avoid empty path when we have multiple optional params |
|
return path || '/'; |
|
} |
|
return { |
|
re, |
|
score, |
|
keys, |
|
parse, |
|
stringify, |
|
}; |
|
} |
|
/** |
|
* Compares an array of numbers as used in PathParser.score and returns a |
|
* number. This function can be used to `sort` an array |
|
* |
|
* @param a - first array of numbers |
|
* @param b - second array of numbers |
|
* @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b |
|
* should be sorted first |
|
*/ |
|
function compareScoreArray(a, b) { |
|
let i = 0; |
|
while (i < a.length && i < b.length) { |
|
const diff = b[i] - a[i]; |
|
// only keep going if diff === 0 |
|
if (diff) |
|
return diff; |
|
i++; |
|
} |
|
// if the last subsegment was Static, the shorter segments should be sorted first |
|
// otherwise sort the longest segment first |
|
if (a.length < b.length) { |
|
return a.length === 1 && a[0] === 40 /* PathScore.Static */ + 40 /* PathScore.Segment */ |
|
? -1 |
|
: 1; |
|
} |
|
else if (a.length > b.length) { |
|
return b.length === 1 && b[0] === 40 /* PathScore.Static */ + 40 /* PathScore.Segment */ |
|
? 1 |
|
: -1; |
|
} |
|
return 0; |
|
} |
|
/** |
|
* Compare function that can be used with `sort` to sort an array of PathParser |
|
* |
|
* @param a - first PathParser |
|
* @param b - second PathParser |
|
* @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b |
|
*/ |
|
function comparePathParserScore(a, b) { |
|
let i = 0; |
|
const aScore = a.score; |
|
const bScore = b.score; |
|
while (i < aScore.length && i < bScore.length) { |
|
const comp = compareScoreArray(aScore[i], bScore[i]); |
|
// do not return if both are equal |
|
if (comp) |
|
return comp; |
|
i++; |
|
} |
|
if (Math.abs(bScore.length - aScore.length) === 1) { |
|
if (isLastScoreNegative(aScore)) |
|
return 1; |
|
if (isLastScoreNegative(bScore)) |
|
return -1; |
|
} |
|
// if a and b share the same score entries but b has more, sort b first |
|
return bScore.length - aScore.length; |
|
// this is the ternary version |
|
// return aScore.length < bScore.length |
|
// ? 1 |
|
// : aScore.length > bScore.length |
|
// ? -1 |
|
// : 0 |
|
} |
|
/** |
|
* This allows detecting splats at the end of a path: /home/:id(.*)* |
|
* |
|
* @param score - score to check |
|
* @returns true if the last entry is negative |
|
*/ |
|
function isLastScoreNegative(score) { |
|
const last = score[score.length - 1]; |
|
return score.length > 0 && last[last.length - 1] < 0; |
|
} |
|
|
|
const ROOT_TOKEN = { |
|
type: 0 /* TokenType.Static */, |
|
value: '', |
|
}; |
|
const VALID_PARAM_RE = /[a-zA-Z0-9_]/; |
|
// After some profiling, the cache seems to be unnecessary because tokenizePath |
|
// (the slowest part of adding a route) is very fast |
|
// const tokenCache = new Map<string, Token[][]>() |
|
function tokenizePath(path) { |
|
if (!path) |
|
return [[]]; |
|
if (path === '/') |
|
return [[ROOT_TOKEN]]; |
|
if (!path.startsWith('/')) { |
|
throw new Error(`Invalid path "${path}"`); |
|
} |
|
// if (tokenCache.has(path)) return tokenCache.get(path)! |
|
function crash(message) { |
|
throw new Error(`ERR (${state})/"${buffer}": ${message}`); |
|
} |
|
let state = 0 /* TokenizerState.Static */; |
|
let previousState = state; |
|
const tokens = []; |
|
// the segment will always be valid because we get into the initial state |
|
// with the leading / |
|
let segment; |
|
function finalizeSegment() { |
|
if (segment) |
|
tokens.push(segment); |
|
segment = []; |
|
} |
|
// index on the path |
|
let i = 0; |
|
// char at index |
|
let char; |
|
// buffer of the value read |
|
let buffer = ''; |
|
// custom regexp for a param |
|
let customRe = ''; |
|
function consumeBuffer() { |
|
if (!buffer) |
|
return; |
|
if (state === 0 /* TokenizerState.Static */) { |
|
segment.push({ |
|
type: 0 /* TokenType.Static */, |
|
value: buffer, |
|
}); |
|
} |
|
else if (state === 1 /* TokenizerState.Param */ || |
|
state === 2 /* TokenizerState.ParamRegExp */ || |
|
state === 3 /* TokenizerState.ParamRegExpEnd */) { |
|
if (segment.length > 1 && (char === '*' || char === '+')) |
|
crash(`A repeatable param (${buffer}) must be alone in its segment. eg: '/:ids+.`); |
|
segment.push({ |
|
type: 1 /* TokenType.Param */, |
|
value: buffer, |
|
regexp: customRe, |
|
repeatable: char === '*' || char === '+', |
|
optional: char === '*' || char === '?', |
|
}); |
|
} |
|
else { |
|
crash('Invalid state to consume buffer'); |
|
} |
|
buffer = ''; |
|
} |
|
function addCharToBuffer() { |
|
buffer += char; |
|
} |
|
while (i < path.length) { |
|
char = path[i++]; |
|
if (char === '\\' && state !== 2 /* TokenizerState.ParamRegExp */) { |
|
previousState = state; |
|
state = 4 /* TokenizerState.EscapeNext */; |
|
continue; |
|
} |
|
switch (state) { |
|
case 0 /* TokenizerState.Static */: |
|
if (char === '/') { |
|
if (buffer) { |
|
consumeBuffer(); |
|
} |
|
finalizeSegment(); |
|
} |
|
else if (char === ':') { |
|
consumeBuffer(); |
|
state = 1 /* TokenizerState.Param */; |
|
} |
|
else { |
|
addCharToBuffer(); |
|
} |
|
break; |
|
case 4 /* TokenizerState.EscapeNext */: |
|
addCharToBuffer(); |
|
state = previousState; |
|
break; |
|
case 1 /* TokenizerState.Param */: |
|
if (char === '(') { |
|
state = 2 /* TokenizerState.ParamRegExp */; |
|
} |
|
else if (VALID_PARAM_RE.test(char)) { |
|
addCharToBuffer(); |
|
} |
|
else { |
|
consumeBuffer(); |
|
state = 0 /* TokenizerState.Static */; |
|
// go back one character if we were not modifying |
|
if (char !== '*' && char !== '?' && char !== '+') |
|
i--; |
|
} |
|
break; |
|
case 2 /* TokenizerState.ParamRegExp */: |
|
// TODO: is it worth handling nested regexp? like :p(?:prefix_([^/]+)_suffix) |
|
// it already works by escaping the closing ) |
|
// https://paths.esm.dev/?p=AAMeJbiAwQEcDKbAoAAkP60PG2R6QAvgNaA6AFACM2ABuQBB# |
|
// is this really something people need since you can also write |
|
// /prefix_:p()_suffix |
|
if (char === ')') { |
|
// handle the escaped ) |
|
if (customRe[customRe.length - 1] == '\\') |
|
customRe = customRe.slice(0, -1) + char; |
|
else |
|
state = 3 /* TokenizerState.ParamRegExpEnd */; |
|
} |
|
else { |
|
customRe += char; |
|
} |
|
break; |
|
case 3 /* TokenizerState.ParamRegExpEnd */: |
|
// same as finalizing a param |
|
consumeBuffer(); |
|
state = 0 /* TokenizerState.Static */; |
|
// go back one character if we were not modifying |
|
if (char !== '*' && char !== '?' && char !== '+') |
|
i--; |
|
customRe = ''; |
|
break; |
|
default: |
|
crash('Unknown state'); |
|
break; |
|
} |
|
} |
|
if (state === 2 /* TokenizerState.ParamRegExp */) |
|
crash(`Unfinished custom RegExp for param "${buffer}"`); |
|
consumeBuffer(); |
|
finalizeSegment(); |
|
// tokenCache.set(path, tokens) |
|
return tokens; |
|
} |
|
|
|
function createRouteRecordMatcher(record, parent, options) { |
|
const parser = tokensToParser(tokenizePath(record.path), options); |
|
const matcher = 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; |
|
} |
|
|
|
/** |
|
* Creates a Router Matcher. |
|
* |
|
* @internal |
|
* @param routes - array of initial routes |
|
* @param globalOptions - global route options |
|
*/ |
|
function createRouterMatcher(routes, globalOptions) { |
|
// normalized ordered array of matchers |
|
const matchers = []; |
|
const matcherMap = new Map(); |
|
globalOptions = mergeOptions({ strict: false, end: true, sensitive: false }, globalOptions); |
|
function getRecordMatcher(name) { |
|
return matcherMap.get(name); |
|
} |
|
function addRoute(record, parent, originalRecord) { |
|
// used later on to remove by name |
|
const isRootAdd = !originalRecord; |
|
const mainNormalizedRecord = normalizeRouteRecord(record); |
|
// we might be the child of an alias |
|
mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record; |
|
const options = mergeOptions(globalOptions, record); |
|
// generate an array of records to correctly handle aliases |
|
const normalizedRecords = [ |
|
mainNormalizedRecord, |
|
]; |
|
if ('alias' in record) { |
|
const aliases = typeof record.alias === 'string' ? [record.alias] : record.alias; |
|
for (const alias of aliases) { |
|
normalizedRecords.push(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; |
|
let originalMatcher; |
|
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); |
|
} |
|
// create the object beforehand, so it can be passed to children |
|
matcher = createRouteRecordMatcher(normalizedRecord, parent, options); |
|
// 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); |
|
} |
|
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)) |
|
removeRoute(record.name); |
|
} |
|
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) |
|
// } |
|
// 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 ((matcher.record.components && |
|
Object.keys(matcher.record.components).length) || |
|
matcher.record.name || |
|
matcher.record.redirect) { |
|
insertMatcher(matcher); |
|
} |
|
} |
|
return originalMatcher |
|
? () => { |
|
// since other matchers are aliases, they should be removed by the original matcher |
|
removeRoute(originalMatcher); |
|
} |
|
: noop; |
|
} |
|
function removeRoute(matcherRef) { |
|
if (isRouteName(matcherRef)) { |
|
const matcher = matcherMap.get(matcherRef); |
|
if (matcher) { |
|
matcherMap.delete(matcherRef); |
|
matchers.splice(matchers.indexOf(matcher), 1); |
|
matcher.children.forEach(removeRoute); |
|
matcher.alias.forEach(removeRoute); |
|
} |
|
} |
|
else { |
|
const index = matchers.indexOf(matcherRef); |
|
if (index > -1) { |
|
matchers.splice(index, 1); |
|
if (matcherRef.record.name) |
|
matcherMap.delete(matcherRef.record.name); |
|
matcherRef.children.forEach(removeRoute); |
|
matcherRef.alias.forEach(removeRoute); |
|
} |
|
} |
|
} |
|
function getRoutes() { |
|
return matchers; |
|
} |
|
function insertMatcher(matcher) { |
|
let i = 0; |
|
while (i < matchers.length && |
|
comparePathParserScore(matcher, matchers[i]) >= 0 && |
|
// Adding children with empty path should still appear before the parent |
|
// https://github.com/vuejs/router/issues/1124 |
|
(matcher.record.path !== matchers[i].record.path || |
|
!isRecordChildOf(matcher, matchers[i]))) |
|
i++; |
|
matchers.splice(i, 0, matcher); |
|
// only add the original record to the name map |
|
if (matcher.record.name && !isAliasRecord(matcher)) |
|
matcherMap.set(matcher.record.name, matcher); |
|
} |
|
function resolve(location, currentLocation) { |
|
let matcher; |
|
let params = {}; |
|
let path; |
|
let name; |
|
if ('name' in location && location.name) { |
|
matcher = matcherMap.get(location.name); |
|
if (!matcher) |
|
throw createRouterError(1 /* ErrorTypes.MATCHER_NOT_FOUND */, { |
|
location, |
|
}); |
|
name = matcher.record.name; |
|
params = assign( |
|
// paramsFromLocation is a new object |
|
paramsFromLocation(currentLocation.params, |
|
// only keep params that exist in the resolved location |
|
// TODO: only keep optional params coming from a parent record |
|
matcher.keys.filter(k => !k.optional).map(k => k.name)), |
|
// discard any existing params in the current location that do not exist here |
|
// #1497 this ensures better active/exact matching |
|
location.params && |
|
paramsFromLocation(location.params, matcher.keys.map(k => k.name))); |
|
// throws if cannot be stringified |
|
path = matcher.stringify(params); |
|
} |
|
else if ('path' in location) { |
|
// no need to resolve the path with the matcher as it was provided |
|
// this also allows the user to control the encoding |
|
path = location.path; |
|
matcher = matchers.find(m => m.re.test(path)); |
|
// matcher should have a value after the loop |
|
if (matcher) { |
|
// we know the matcher works because we tested the regexp |
|
params = matcher.parse(path); |
|
name = matcher.record.name; |
|
} |
|
// location is a relative path |
|
} |
|
else { |
|
// match by name or path of current route |
|
matcher = currentLocation.name |
|
? matcherMap.get(currentLocation.name) |
|
: matchers.find(m => m.re.test(currentLocation.path)); |
|
if (!matcher) |
|
throw createRouterError(1 /* ErrorTypes.MATCHER_NOT_FOUND */, { |
|
location, |
|
currentLocation, |
|
}); |
|
name = matcher.record.name; |
|
// since we are navigating to the same location, we don't need to pick the |
|
// params like when `name` is provided |
|
params = assign({}, currentLocation.params, location.params); |
|
path = matcher.stringify(params); |
|
} |
|
const matched = []; |
|
let parentMatcher = matcher; |
|
while (parentMatcher) { |
|
// reversed order so parents are at the beginning |
|
matched.unshift(parentMatcher.record); |
|
parentMatcher = parentMatcher.parent; |
|
} |
|
return { |
|
name, |
|
path, |
|
params, |
|
matched, |
|
meta: mergeMetaFields(matched), |
|
}; |
|
} |
|
// add initial routes |
|
routes.forEach(route => addRoute(route)); |
|
return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }; |
|
} |
|
function paramsFromLocation(params, keys) { |
|
const newParams = {}; |
|
for (const key of keys) { |
|
if (key in params) |
|
newParams[key] = params[key]; |
|
} |
|
return newParams; |
|
} |
|
/** |
|
* Normalizes a RouteRecordRaw. Creates a copy |
|
* |
|
* @param record |
|
* @returns the normalized version |
|
*/ |
|
function normalizeRouteRecord(record) { |
|
return { |
|
path: record.path, |
|
redirect: record.redirect, |
|
name: record.name, |
|
meta: record.meta || {}, |
|
aliasOf: undefined, |
|
beforeEnter: record.beforeEnter, |
|
props: normalizeRecordProps(record), |
|
children: record.children || [], |
|
instances: {}, |
|
leaveGuards: new Set(), |
|
updateGuards: new Set(), |
|
enterCallbacks: {}, |
|
components: 'components' in record |
|
? record.components || null |
|
: record.component && { default: record.component }, |
|
}; |
|
} |
|
/** |
|
* Normalize the optional `props` in a record to always be an object similar to |
|
* components. Also accept a boolean for components. |
|
* @param record |
|
*/ |
|
function normalizeRecordProps(record) { |
|
const propsObject = {}; |
|
// props does not exist on redirect records, but we can set false directly |
|
const props = record.props || false; |
|
if ('component' in record) { |
|
propsObject.default = props; |
|
} |
|
else { |
|
// NOTE: we could also allow a function to be applied to every component. |
|
// Would need user feedback for use cases |
|
for (const name in record.components) |
|
propsObject[name] = typeof props === 'object' ? props[name] : props; |
|
} |
|
return propsObject; |
|
} |
|
/** |
|
* Checks if a record or any of its parent is an alias |
|
* @param record |
|
*/ |
|
function isAliasRecord(record) { |
|
while (record) { |
|
if (record.record.aliasOf) |
|
return true; |
|
record = record.parent; |
|
} |
|
return false; |
|
} |
|
/** |
|
* Merge meta fields of an array of records |
|
* |
|
* @param matched - array of matched records |
|
*/ |
|
function mergeMetaFields(matched) { |
|
return matched.reduce((meta, record) => assign(meta, record.meta), {}); |
|
} |
|
function mergeOptions(defaults, partialOptions) { |
|
const options = {}; |
|
for (const key in defaults) { |
|
options[key] = key in partialOptions ? partialOptions[key] : defaults[key]; |
|
} |
|
return options; |
|
} |
|
function isRecordChildOf(record, parent) { |
|
return parent.children.some(child => child === record || isRecordChildOf(record, child)); |
|
} |
|
|
|
/** |
|
* Encoding Rules ␣ = Space Path: ␣ " < > # ? { } Query: ␣ " < > # & = Hash: ␣ " |
|
* < > ` |
|
* |
|
* On top of that, the RFC3986 (https://tools.ietf.org/html/rfc3986#section-2.2) |
|
* defines some extra characters to be encoded. Most browsers do not encode them |
|
* in encodeURI https://github.com/whatwg/url/issues/369, so it may be safer to |
|
* also encode `!'()*`. Leaving un-encoded only ASCII alphanumeric(`a-zA-Z0-9`) |
|
* plus `-._~`. This extra safety should be applied to query by patching the |
|
* string returned by encodeURIComponent encodeURI also encodes `[\]^`. `\` |
|
* should be encoded to avoid ambiguity. Browsers (IE, FF, C) transform a `\` |
|
* into a `/` if directly typed in. The _backtick_ (`````) should also be |
|
* encoded everywhere because some browsers like FF encode it when directly |
|
* written while others don't. Safari and IE don't encode ``"<>{}``` in hash. |
|
*/ |
|
// const EXTRA_RESERVED_RE = /[!'()*]/g |
|
// const encodeReservedReplacer = (c: string) => '%' + c.charCodeAt(0).toString(16) |
|
const HASH_RE = /#/g; // %23 |
|
const AMPERSAND_RE = /&/g; // %26 |
|
const SLASH_RE = /\//g; // %2F |
|
const EQUAL_RE = /=/g; // %3D |
|
const IM_RE = /\?/g; // %3F |
|
const PLUS_RE = /\+/g; // %2B |
|
/** |
|
* NOTE: It's not clear to me if we should encode the + symbol in queries, it |
|
* seems to be less flexible than not doing so and I can't find out the legacy |
|
* systems requiring this for regular requests like text/html. In the standard, |
|
* the encoding of the plus character is only mentioned for |
|
* application/x-www-form-urlencoded |
|
* (https://url.spec.whatwg.org/#urlencoded-parsing) and most browsers seems lo |
|
* leave the plus character as is in queries. To be more flexible, we allow the |
|
* plus character on the query, but it can also be manually encoded by the user. |
|
* |
|
* Resources: |
|
* - https://url.spec.whatwg.org/#urlencoded-parsing |
|
* - https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20 |
|
*/ |
|
const ENC_BRACKET_OPEN_RE = /%5B/g; // [ |
|
const ENC_BRACKET_CLOSE_RE = /%5D/g; // ] |
|
const ENC_CARET_RE = /%5E/g; // ^ |
|
const ENC_BACKTICK_RE = /%60/g; // ` |
|
const ENC_CURLY_OPEN_RE = /%7B/g; // { |
|
const ENC_PIPE_RE = /%7C/g; // | |
|
const ENC_CURLY_CLOSE_RE = /%7D/g; // } |
|
const ENC_SPACE_RE = /%20/g; // } |
|
/** |
|
* Encode characters that need to be encoded on the path, search and hash |
|
* sections of the URL. |
|
* |
|
* @internal |
|
* @param text - string to encode |
|
* @returns encoded string |
|
*/ |
|
function commonEncode(text) { |
|
return encodeURI('' + text) |
|
.replace(ENC_PIPE_RE, '|') |
|
.replace(ENC_BRACKET_OPEN_RE, '[') |
|
.replace(ENC_BRACKET_CLOSE_RE, ']'); |
|
} |
|
/** |
|
* Encode characters that need to be encoded on the hash section of the URL. |
|
* |
|
* @param text - string to encode |
|
* @returns encoded string |
|
*/ |
|
function encodeHash(text) { |
|
return commonEncode(text) |
|
.replace(ENC_CURLY_OPEN_RE, '{') |
|
.replace(ENC_CURLY_CLOSE_RE, '}') |
|
.replace(ENC_CARET_RE, '^'); |
|
} |
|
/** |
|
* Encode characters that need to be encoded query values on the query |
|
* section of the URL. |
|
* |
|
* @param text - string to encode |
|
* @returns encoded string |
|
*/ |
|
function encodeQueryValue(text) { |
|
return (commonEncode(text) |
|
// Encode the space as +, encode the + to differentiate it from the space |
|
.replace(PLUS_RE, '%2B') |
|
.replace(ENC_SPACE_RE, '+') |
|
.replace(HASH_RE, '%23') |
|
.replace(AMPERSAND_RE, '%26') |
|
.replace(ENC_BACKTICK_RE, '`') |
|
.replace(ENC_CURLY_OPEN_RE, '{') |
|
.replace(ENC_CURLY_CLOSE_RE, '}') |
|
.replace(ENC_CARET_RE, '^')); |
|
} |
|
/** |
|
* Like `encodeQueryValue` but also encodes the `=` character. |
|
* |
|
* @param text - string to encode |
|
*/ |
|
function encodeQueryKey(text) { |
|
return encodeQueryValue(text).replace(EQUAL_RE, '%3D'); |
|
} |
|
/** |
|
* Encode characters that need to be encoded on the path section of the URL. |
|
* |
|
* @param text - string to encode |
|
* @returns encoded string |
|
*/ |
|
function encodePath(text) { |
|
return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F'); |
|
} |
|
/** |
|
* Encode characters that need to be encoded on the path section of the URL as a |
|
* param. This function encodes everything {@link encodePath} does plus the |
|
* slash (`/`) character. If `text` is `null` or `undefined`, returns an empty |
|
* string instead. |
|
* |
|
* @param text - string to encode |
|
* @returns encoded string |
|
*/ |
|
function encodeParam(text) { |
|
return text == null ? '' : encodePath(text).replace(SLASH_RE, '%2F'); |
|
} |
|
/** |
|
* Decode text using `decodeURIComponent`. Returns the original text if it |
|
* fails. |
|
* |
|
* @param text - string to decode |
|
* @returns decoded string |
|
*/ |
|
function decode(text) { |
|
try { |
|
return decodeURIComponent('' + text); |
|
} |
|
catch (err) { |
|
} |
|
return '' + text; |
|
} |
|
|
|
/** |
|
* Transforms a queryString into a {@link LocationQuery} object. Accept both, a |
|
* version with the leading `?` and without Should work as URLSearchParams |
|
|
|
* @internal |
|
* |
|
* @param search - search string to parse |
|
* @returns a query object |
|
*/ |
|
function parseQuery(search) { |
|
const query = {}; |
|
// 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('&'); |
|
for (let i = 0; i < searchParams.length; ++i) { |
|
// pre decode the + into space |
|
const searchParam = searchParams[i].replace(PLUS_RE, ' '); |
|
// allow the = character |
|
const eqPos = searchParam.indexOf('='); |
|
const key = decode(eqPos < 0 ? searchParam : searchParam.slice(0, eqPos)); |
|
const value = eqPos < 0 ? null : decode(searchParam.slice(eqPos + 1)); |
|
if (key in query) { |
|
// an extra variable for ts types |
|
let currentValue = query[key]; |
|
if (!isArray(currentValue)) { |
|
currentValue = query[key] = [currentValue]; |
|
} |
|
currentValue.push(value); |
|
} |
|
else { |
|
query[key] = value; |
|
} |
|
} |
|
return query; |
|
} |
|
/** |
|
* Stringifies a {@link LocationQueryRaw} object. Like `URLSearchParams`, it |
|
* doesn't prepend a `?` |
|
* |
|
* @internal |
|
* |
|
* @param query - query object to stringify |
|
* @returns string version of the query without the leading `?` |
|
*/ |
|
function stringifyQuery(query) { |
|
let search = ''; |
|
for (let key in query) { |
|
const value = query[key]; |
|
key = encodeQueryKey(key); |
|
if (value == null) { |
|
// only null adds the value |
|
if (value !== undefined) { |
|
search += (search.length ? '&' : '') + key; |
|
} |
|
continue; |
|
} |
|
// keep null values |
|
const values = isArray(value) |
|
? value.map(v => v && encodeQueryValue(v)) |
|
: [value && encodeQueryValue(value)]; |
|
values.forEach(value => { |
|
// skip undefined values in arrays as if they were not present |
|
// smaller code than using filter |
|
if (value !== undefined) { |
|
// only append & with non-empty search |
|
search += (search.length ? '&' : '') + key; |
|
if (value != null) |
|
search += '=' + value; |
|
} |
|
}); |
|
} |
|
return search; |
|
} |
|
/** |
|
* Transforms a {@link LocationQueryRaw} into a {@link LocationQuery} by casting |
|
* numbers into strings, removing keys with an undefined value and replacing |
|
* undefined with null in arrays |
|
* |
|
* @param query - query object to normalize |
|
* @returns a normalized query object |
|
*/ |
|
function normalizeQuery(query) { |
|
const normalizedQuery = {}; |
|
for (const key in query) { |
|
const value = query[key]; |
|
if (value !== undefined) { |
|
normalizedQuery[key] = isArray(value) |
|
? value.map(v => (v == null ? null : '' + v)) |
|
: value == null |
|
? value |
|
: '' + value; |
|
} |
|
} |
|
return normalizedQuery; |
|
} |
|
|
|
/** |
|
* RouteRecord being rendered by the closest ancestor Router View. Used for |
|
* `onBeforeRouteUpdate` and `onBeforeRouteLeave`. rvlm stands for Router View |
|
* Location Matched |
|
* |
|
* @internal |
|
*/ |
|
const matchedRouteKey = Symbol(''); |
|
/** |
|
* Allows overriding the router view depth to control which component in |
|
* `matched` is rendered. rvd stands for Router View Depth |
|
* |
|
* @internal |
|
*/ |
|
const viewDepthKey = Symbol(''); |
|
/** |
|
* Allows overriding the router instance returned by `useRouter` in tests. r |
|
* stands for router |
|
* |
|
* @internal |
|
*/ |
|
const routerKey = Symbol(''); |
|
/** |
|
* Allows overriding the current route returned by `useRoute` in tests. rl |
|
* stands for route location |
|
* |
|
* @internal |
|
*/ |
|
const routeLocationKey = Symbol(''); |
|
/** |
|
* Allows overriding the current route used by router-view. Internally this is |
|
* used when the `route` prop is passed. |
|
* |
|
* @internal |
|
*/ |
|
const routerViewLocationKey = Symbol(''); |
|
|
|
/** |
|
* Create a list of callbacks that can be reset. Used to create before and after navigation guards list |
|
*/ |
|
function useCallbacks() { |
|
let handlers = []; |
|
function add(handler) { |
|
handlers.push(handler); |
|
return () => { |
|
const i = handlers.indexOf(handler); |
|
if (i > -1) |
|
handlers.splice(i, 1); |
|
}; |
|
} |
|
function reset() { |
|
handlers = []; |
|
} |
|
return { |
|
add, |
|
list: () => handlers.slice(), |
|
reset, |
|
}; |
|
} |
|
|
|
function registerGuard(record, name, guard) { |
|
const removeFromList = () => { |
|
record[name].delete(guard); |
|
}; |
|
vue.onUnmounted(removeFromList); |
|
vue.onDeactivated(removeFromList); |
|
vue.onActivated(() => { |
|
record[name].add(guard); |
|
}); |
|
record[name].add(guard); |
|
} |
|
/** |
|
* Add a navigation guard that triggers whenever the component for the current |
|
* location is about to be left. Similar to {@link beforeRouteLeave} but can be |
|
* used in any component. The guard is removed when the component is unmounted. |
|
* |
|
* @param leaveGuard - {@link NavigationGuard} |
|
*/ |
|
function onBeforeRouteLeave(leaveGuard) { |
|
const activeRecord = vue.inject(matchedRouteKey, |
|
// to avoid warning |
|
{}).value; |
|
if (!activeRecord) { |
|
return; |
|
} |
|
registerGuard(activeRecord, 'leaveGuards', leaveGuard); |
|
} |
|
/** |
|
* Add a navigation guard that triggers whenever the current location is about |
|
* to be updated. Similar to {@link beforeRouteUpdate} but can be used in any |
|
* component. The guard is removed when the component is unmounted. |
|
* |
|
* @param updateGuard - {@link NavigationGuard} |
|
*/ |
|
function onBeforeRouteUpdate(updateGuard) { |
|
const activeRecord = vue.inject(matchedRouteKey, |
|
// to avoid warning |
|
{}).value; |
|
if (!activeRecord) { |
|
return; |
|
} |
|
registerGuard(activeRecord, 'updateGuards', updateGuard); |
|
} |
|
function guardToPromiseFn(guard, to, from, record, name) { |
|
// keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place |
|
const enterCallbackArray = record && |
|
// name is defined if record is because of the function overload |
|
(record.enterCallbacks[name] = record.enterCallbacks[name] || []); |
|
return () => new Promise((resolve, reject) => { |
|
const next = (valid) => { |
|
if (valid === false) { |
|
reject(createRouterError(4 /* ErrorTypes.NAVIGATION_ABORTED */, { |
|
from, |
|
to, |
|
})); |
|
} |
|
else if (valid instanceof Error) { |
|
reject(valid); |
|
} |
|
else if (isRouteLocation(valid)) { |
|
reject(createRouterError(2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */, { |
|
from: to, |
|
to: valid, |
|
})); |
|
} |
|
else { |
|
if (enterCallbackArray && |
|
// since enterCallbackArray is truthy, both record and name also are |
|
record.enterCallbacks[name] === enterCallbackArray && |
|
typeof valid === 'function') { |
|
enterCallbackArray.push(valid); |
|
} |
|
resolve(); |
|
} |
|
}; |
|
// wrapping with Promise.resolve allows it to work with both async and sync guards |
|
const guardReturn = guard.call(record && record.instances[name], to, from, next); |
|
let guardCall = Promise.resolve(guardReturn); |
|
if (guard.length < 3) |
|
guardCall = guardCall.then(next); |
|
guardCall.catch(err => reject(err)); |
|
}); |
|
} |
|
function extractComponentsGuards(matched, guardType, to, from) { |
|
const guards = []; |
|
for (const record of matched) { |
|
for (const name in record.components) { |
|
let rawComponent = record.components[name]; |
|
// skip update and leave guards if the route component is not mounted |
|
if (guardType !== 'beforeRouteEnter' && !record.instances[name]) |
|
continue; |
|
if (isRouteComponent(rawComponent)) { |
|
// __vccOpts is added by vue-class-component and contain the regular options |
|
const options = rawComponent.__vccOpts || rawComponent; |
|
const guard = options[guardType]; |
|
guard && guards.push(guardToPromiseFn(guard, to, from, record, name)); |
|
} |
|
else { |
|
// start requesting the chunk already |
|
let componentPromise = rawComponent(); |
|
guards.push(() => componentPromise.then(resolved => { |
|
if (!resolved) |
|
return Promise.reject(new Error(`Couldn't resolve component "${name}" at "${record.path}"`)); |
|
const resolvedComponent = isESModule(resolved) |
|
? resolved.default |
|
: resolved; |
|
// replace the function with the resolved component |
|
// cannot be null or undefined because we went into the for loop |
|
record.components[name] = resolvedComponent; |
|
// __vccOpts is added by vue-class-component and contain the regular options |
|
const options = resolvedComponent.__vccOpts || resolvedComponent; |
|
const guard = options[guardType]; |
|
return guard && guardToPromiseFn(guard, to, from, record, name)(); |
|
})); |
|
} |
|
} |
|
} |
|
return guards; |
|
} |
|
/** |
|
* Allows differentiating lazy components from functional components and vue-class-component |
|
* @internal |
|
* |
|
* @param component |
|
*/ |
|
function isRouteComponent(component) { |
|
return (typeof component === 'object' || |
|
'displayName' in component || |
|
'props' in component || |
|
'__vccOpts' in component); |
|
} |
|
/** |
|
* Ensures a route is loaded, so it can be passed as o prop to `<RouterView>`. |
|
* |
|
* @param route - resolved route to load |
|
*/ |
|
function loadRouteLocation(route) { |
|
return route.matched.every(record => record.redirect) |
|
? Promise.reject(new Error('Cannot load a route that redirects.')) |
|
: Promise.all(route.matched.map(record => record.components && |
|
Promise.all(Object.keys(record.components).reduce((promises, name) => { |
|
const rawComponent = record.components[name]; |
|
if (typeof rawComponent === 'function' && |
|
!('displayName' in rawComponent)) { |
|
promises.push(rawComponent().then(resolved => { |
|
if (!resolved) |
|
return Promise.reject(new Error(`Couldn't resolve component "${name}" at "${record.path}". Ensure you passed a function that returns a promise.`)); |
|
const resolvedComponent = isESModule(resolved) |
|
? resolved.default |
|
: resolved; |
|
// replace the function with the resolved component |
|
// cannot be null or undefined because we went into the for loop |
|
record.components[name] = resolvedComponent; |
|
return; |
|
})); |
|
} |
|
return promises; |
|
}, [])))).then(() => route); |
|
} |
|
|
|
// TODO: we could allow currentRoute as a prop to expose `isActive` and |
|
// `isExactActive` behavior should go through an RFC |
|
function useLink(props) { |
|
const router = vue.inject(routerKey); |
|
const currentRoute = vue.inject(routeLocationKey); |
|
const route = vue.computed(() => router.resolve(vue.unref(props.to))); |
|
const activeRecordIndex = vue.computed(() => { |
|
const { matched } = route.value; |
|
const { length } = matched; |
|
const routeMatched = matched[length - 1]; |
|
const currentMatched = currentRoute.matched; |
|
if (!routeMatched || !currentMatched.length) |
|
return -1; |
|
const index = currentMatched.findIndex(isSameRouteRecord.bind(null, routeMatched)); |
|
if (index > -1) |
|
return index; |
|
// possible parent record |
|
const parentRecordPath = getOriginalPath(matched[length - 2]); |
|
return ( |
|
// we are dealing with nested routes |
|
length > 1 && |
|
// if the parent and matched route have the same path, this link is |
|
// referring to the empty child. Or we currently are on a different |
|
// child of the same parent |
|
getOriginalPath(routeMatched) === parentRecordPath && |
|
// avoid comparing the child with its parent |
|
currentMatched[currentMatched.length - 1].path !== parentRecordPath |
|
? currentMatched.findIndex(isSameRouteRecord.bind(null, matched[length - 2])) |
|
: index); |
|
}); |
|
const isActive = vue.computed(() => activeRecordIndex.value > -1 && |
|
includesParams(currentRoute.params, route.value.params)); |
|
const isExactActive = vue.computed(() => activeRecordIndex.value > -1 && |
|
activeRecordIndex.value === currentRoute.matched.length - 1 && |
|
isSameRouteLocationParams(currentRoute.params, route.value.params)); |
|
function navigate(e = {}) { |
|
if (guardEvent(e)) { |
|
return router[vue.unref(props.replace) ? 'replace' : 'push'](vue.unref(props.to) |
|
// avoid uncaught errors are they are logged anyway |
|
).catch(noop); |
|
} |
|
return Promise.resolve(); |
|
} |
|
/** |
|
* NOTE: update {@link _RouterLinkI}'s `$slots` type when updating this |
|
*/ |
|
return { |
|
route, |
|
href: vue.computed(() => route.value.href), |
|
isActive, |
|
isExactActive, |
|
navigate, |
|
}; |
|
} |
|
const RouterLinkImpl = /*#__PURE__*/ vue.defineComponent({ |
|
name: 'RouterLink', |
|
compatConfig: { MODE: 3 }, |
|
props: { |
|
to: { |
|
type: [String, Object], |
|
required: true, |
|
}, |
|
replace: Boolean, |
|
activeClass: String, |
|
// inactiveClass: String, |
|
exactActiveClass: String, |
|
custom: Boolean, |
|
ariaCurrentValue: { |
|
type: String, |
|
default: 'page', |
|
}, |
|
}, |
|
useLink, |
|
setup(props, { slots }) { |
|
const link = vue.reactive(useLink(props)); |
|
const { options } = vue.inject(routerKey); |
|
const elClass = vue.computed(() => ({ |
|
[getLinkClass(props.activeClass, options.linkActiveClass, 'router-link-active')]: link.isActive, |
|
// [getLinkClass( |
|
// props.inactiveClass, |
|
// options.linkInactiveClass, |
|
// 'router-link-inactive' |
|
// )]: !link.isExactActive, |
|
[getLinkClass(props.exactActiveClass, options.linkExactActiveClass, 'router-link-exact-active')]: link.isExactActive, |
|
})); |
|
return () => { |
|
const children = slots.default && slots.default(link); |
|
return props.custom |
|
? children |
|
: vue.h('a', { |
|
'aria-current': link.isExactActive |
|
? props.ariaCurrentValue |
|
: null, |
|
href: link.href, |
|
// this would override user added attrs but Vue will still add |
|
// the listener, so we end up triggering both |
|
onClick: link.navigate, |
|
class: elClass.value, |
|
}, children); |
|
}; |
|
}, |
|
}); |
|
// export the public type for h/tsx inference |
|
// also to avoid inline import() in generated d.ts files |
|
/** |
|
* Component to render a link that triggers a navigation on click. |
|
*/ |
|
const RouterLink = RouterLinkImpl; |
|
function guardEvent(e) { |
|
// don't redirect with control keys |
|
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) |
|
return; |
|
// don't redirect when preventDefault called |
|
if (e.defaultPrevented) |
|
return; |
|
// don't redirect on right click |
|
if (e.button !== undefined && e.button !== 0) |
|
return; |
|
// don't redirect if `target="_blank"` |
|
// @ts-expect-error getAttribute does exist |
|
if (e.currentTarget && e.currentTarget.getAttribute) { |
|
// @ts-expect-error getAttribute exists |
|
const target = e.currentTarget.getAttribute('target'); |
|
if (/\b_blank\b/i.test(target)) |
|
return; |
|
} |
|
// this may be a Weex event which doesn't have this method |
|
if (e.preventDefault) |
|
e.preventDefault(); |
|
return true; |
|
} |
|
function includesParams(outer, inner) { |
|
for (const key in inner) { |
|
const innerValue = inner[key]; |
|
const outerValue = outer[key]; |
|
if (typeof innerValue === 'string') { |
|
if (innerValue !== outerValue) |
|
return false; |
|
} |
|
else { |
|
if (!isArray(outerValue) || |
|
outerValue.length !== innerValue.length || |
|
innerValue.some((value, i) => value !== outerValue[i])) |
|
return false; |
|
} |
|
} |
|
return true; |
|
} |
|
/** |
|
* Get the original path value of a record by following its aliasOf |
|
* @param record |
|
*/ |
|
function getOriginalPath(record) { |
|
return record ? (record.aliasOf ? record.aliasOf.path : record.path) : ''; |
|
} |
|
/** |
|
* Utility class to get the active class based on defaults. |
|
* @param propClass |
|
* @param globalClass |
|
* @param defaultClass |
|
*/ |
|
const getLinkClass = (propClass, globalClass, defaultClass) => propClass != null |
|
? propClass |
|
: globalClass != null |
|
? globalClass |
|
: defaultClass; |
|
|
|
const RouterViewImpl = /*#__PURE__*/ vue.defineComponent({ |
|
name: 'RouterView', |
|
// #674 we manually inherit them |
|
inheritAttrs: false, |
|
props: { |
|
name: { |
|
type: String, |
|
default: 'default', |
|
}, |
|
route: Object, |
|
}, |
|
// Better compat for @vue/compat users |
|
// https://github.com/vuejs/router/issues/1315 |
|
compatConfig: { MODE: 3 }, |
|
setup(props, { attrs, slots }) { |
|
const injectedRoute = vue.inject(routerViewLocationKey); |
|
const routeToDisplay = vue.computed(() => props.route || injectedRoute.value); |
|
const injectedDepth = vue.inject(viewDepthKey, 0); |
|
// The depth changes based on empty components option, which allows passthrough routes e.g. routes with children |
|
// that are used to reuse the `path` property |
|
const depth = vue.computed(() => { |
|
let initialDepth = vue.unref(injectedDepth); |
|
const { matched } = routeToDisplay.value; |
|
let matchedRoute; |
|
while ((matchedRoute = matched[initialDepth]) && |
|
!matchedRoute.components) { |
|
initialDepth++; |
|
} |
|
return initialDepth; |
|
}); |
|
const matchedRouteRef = vue.computed(() => routeToDisplay.value.matched[depth.value]); |
|
vue.provide(viewDepthKey, vue.computed(() => depth.value + 1)); |
|
vue.provide(matchedRouteKey, matchedRouteRef); |
|
vue.provide(routerViewLocationKey, routeToDisplay); |
|
const viewRef = vue.ref(); |
|
// watch at the same time the component instance, the route record we are |
|
// rendering, and the name |
|
vue.watch(() => [viewRef.value, matchedRouteRef.value, props.name], ([instance, to, name], [oldInstance, from, oldName]) => { |
|
// copy reused instances |
|
if (to) { |
|
// this will update the instance for new instances as well as reused |
|
// instances when navigating to a new route |
|
to.instances[name] = instance; |
|
// the component instance is reused for a different route or name, so |
|
// we copy any saved update or leave guards. With async setup, the |
|
// mounting component will mount before the matchedRoute changes, |
|
// making instance === oldInstance, so we check if guards have been |
|
// added before. This works because we remove guards when |
|
// unmounting/deactivating components |
|
if (from && from !== to && instance && instance === oldInstance) { |
|
if (!to.leaveGuards.size) { |
|
to.leaveGuards = from.leaveGuards; |
|
} |
|
if (!to.updateGuards.size) { |
|
to.updateGuards = from.updateGuards; |
|
} |
|
} |
|
} |
|
// trigger beforeRouteEnter next callbacks |
|
if (instance && |
|
to && |
|
// if there is no instance but to and from are the same this might be |
|
// the first visit |
|
(!from || !isSameRouteRecord(to, from) || !oldInstance)) { |
|
(to.enterCallbacks[name] || []).forEach(callback => callback(instance)); |
|
} |
|
}, { flush: 'post' }); |
|
return () => { |
|
const route = routeToDisplay.value; |
|
// we need the value at the time we render because when we unmount, we |
|
// navigated to a different location so the value is different |
|
const currentName = props.name; |
|
const matchedRoute = matchedRouteRef.value; |
|
const ViewComponent = matchedRoute && matchedRoute.components[currentName]; |
|
if (!ViewComponent) { |
|
return normalizeSlot(slots.default, { Component: ViewComponent, route }); |
|
} |
|
// props from route configuration |
|
const routePropsOption = matchedRoute.props[currentName]; |
|
const routeProps = routePropsOption |
|
? routePropsOption === true |
|
? route.params |
|
: typeof routePropsOption === 'function' |
|
? routePropsOption(route) |
|
: routePropsOption |
|
: null; |
|
const onVnodeUnmounted = vnode => { |
|
// remove the instance reference to prevent leak |
|
if (vnode.component.isUnmounted) { |
|
matchedRoute.instances[currentName] = null; |
|
} |
|
}; |
|
const component = vue.h(ViewComponent, assign({}, routeProps, attrs, { |
|
onVnodeUnmounted, |
|
ref: viewRef, |
|
})); |
|
return ( |
|
// pass the vnode to the slot as a prop. |
|
// h and <component :is="..."> both accept vnodes |
|
normalizeSlot(slots.default, { Component: component, route }) || |
|
component); |
|
}; |
|
}, |
|
}); |
|
function normalizeSlot(slot, data) { |
|
if (!slot) |
|
return null; |
|
const slotContent = slot(data); |
|
return slotContent.length === 1 ? slotContent[0] : slotContent; |
|
} |
|
// export the public type for h/tsx inference |
|
// also to avoid inline import() in generated d.ts files |
|
/** |
|
* Component to display the current route the user is at. |
|
*/ |
|
const RouterView = RouterViewImpl; |
|
|
|
/** |
|
* Creates a Router instance that can be used by a Vue app. |
|
* |
|
* @param options - {@link RouterOptions} |
|
*/ |
|
function createRouter(options) { |
|
const matcher = createRouterMatcher(options.routes, options); |
|
const parseQuery$1 = options.parseQuery || parseQuery; |
|
const stringifyQuery$1 = options.stringifyQuery || stringifyQuery; |
|
const routerHistory = options.history; |
|
const beforeGuards = useCallbacks(); |
|
const beforeResolveGuards = useCallbacks(); |
|
const afterGuards = useCallbacks(); |
|
const currentRoute = vue.shallowRef(START_LOCATION_NORMALIZED); |
|
let pendingLocation = 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 = |
|
// @ts-expect-error: intentionally avoid the type check |
|
applyToParams.bind(null, decode); |
|
function addRoute(parentOrRoute, route) { |
|
let parent; |
|
let record; |
|
if (isRouteName(parentOrRoute)) { |
|
parent = matcher.getRecordMatcher(parentOrRoute); |
|
record = route; |
|
} |
|
else { |
|
record = parentOrRoute; |
|
} |
|
return matcher.addRoute(record, parent); |
|
} |
|
function removeRoute(name) { |
|
const recordMatcher = matcher.getRecordMatcher(name); |
|
if (recordMatcher) { |
|
matcher.removeRoute(recordMatcher); |
|
} |
|
} |
|
function getRoutes() { |
|
return matcher.getRoutes().map(routeMatcher => routeMatcher.record); |
|
} |
|
function hasRoute(name) { |
|
return !!matcher.getRecordMatcher(name); |
|
} |
|
function resolve(rawLocation, 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$1, rawLocation, currentLocation.path); |
|
const matchedRoute = matcher.resolve({ path: locationNormalized.path }, currentLocation); |
|
const href = routerHistory.createHref(locationNormalized.fullPath); |
|
// locationNormalized is always a new object |
|
return assign(locationNormalized, matchedRoute, { |
|
params: decodeParams(matchedRoute.params), |
|
hash: decode(locationNormalized.hash), |
|
redirectedFrom: undefined, |
|
href, |
|
}); |
|
} |
|
let matcherLocation; |
|
// path could be relative in object as well |
|
if ('path' in rawLocation) { |
|
matcherLocation = assign({}, rawLocation, { |
|
path: parseURL(parseQuery$1, 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 || ''; |
|
// 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$1, assign({}, rawLocation, { |
|
hash: encodeHash(hash), |
|
path: matchedRoute.path, |
|
})); |
|
const href = routerHistory.createHref(fullPath); |
|
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$1 === stringifyQuery |
|
? normalizeQuery(rawLocation.query) |
|
: (rawLocation.query || {}), |
|
}, matchedRoute, { |
|
redirectedFrom: undefined, |
|
href, |
|
}); |
|
} |
|
function locationAsObject(to) { |
|
return typeof to === 'string' |
|
? parseURL(parseQuery$1, to, currentRoute.value.path) |
|
: assign({}, to); |
|
} |
|
function checkCanceledNavigation(to, from) { |
|
if (pendingLocation !== to) { |
|
return createRouterError(8 /* ErrorTypes.NAVIGATION_CANCELLED */, { |
|
from, |
|
to, |
|
}); |
|
} |
|
} |
|
function push(to) { |
|
return pushWithRedirect(to); |
|
} |
|
function replace(to) { |
|
return push(assign(locationAsObject(to), { replace: true })); |
|
} |
|
function handleRedirectRecord(to) { |
|
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 = {}; |
|
} |
|
return assign({ |
|
query: to.query, |
|
hash: to.hash, |
|
// avoid transferring params if the redirect has a path |
|
params: 'path' in newTargetLocation ? {} : to.params, |
|
}, newTargetLocation); |
|
} |
|
} |
|
function pushWithRedirect(to, redirectedFrom) { |
|
const targetLocation = (pendingLocation = resolve(to)); |
|
const from = currentRoute.value; |
|
const data = to.state; |
|
const force = to.force; |
|
// to could be a string where `replace` is a function |
|
const replace = to.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; |
|
toLocation.redirectedFrom = redirectedFrom; |
|
let failure; |
|
if (!force && isSameRouteLocation(stringifyQuery$1, from, targetLocation)) { |
|
failure = createRouterError(16 /* 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) => isNavigationFailure(error) |
|
? // navigation redirects still mark the router as ready |
|
isNavigationFailure(error, 2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */) |
|
? error |
|
: markAsReady(error) // also returns the error |
|
: // reject any unknown error |
|
triggerError(error, toLocation, from)) |
|
.then((failure) => { |
|
if (failure) { |
|
if (isNavigationFailure(failure, 2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */)) { |
|
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, from, true, replace, data); |
|
} |
|
triggerAfterEach(toLocation, from, failure); |
|
return failure; |
|
}); |
|
} |
|
/** |
|
* Helper to reject and skip all navigation guards if a new navigation happened |
|
* @param to |
|
* @param from |
|
*/ |
|
function checkCanceledNavigationAndReject(to, from) { |
|
const error = checkCanceledNavigation(to, from); |
|
return error ? Promise.reject(error) : Promise.resolve(); |
|
} |
|
function runWithContext(fn) { |
|
const app = 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, from) { |
|
let guards; |
|
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<Component> |
|
// clear existing enterCallbacks, these are added by extractComponentsGuards |
|
to.matched.forEach(record => (record.enterCallbacks = {})); |
|
// check in-component beforeRouteEnter |
|
guards = extractComponentsGuards(enteringRecords, 'beforeRouteEnter', to, from); |
|
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, 8 /* ErrorTypes.NAVIGATION_CANCELLED */) |
|
? err |
|
: Promise.reject(err))); |
|
} |
|
function triggerAfterEach(to, from, failure) { |
|
// 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, from, isPush, replace, data) { |
|
// 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 = !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; |
|
// 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); |
|
// 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 }), 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) => { |
|
if (isNavigationFailure(error, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 8 /* ErrorTypes.NAVIGATION_CANCELLED */)) { |
|
return error; |
|
} |
|
if (isNavigationFailure(error, 2 /* 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(error.to, 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, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | |
|
16 /* 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) => { |
|
failure = |
|
failure || |
|
finalizeNavigation( |
|
// after navigation, all matched components are resolved |
|
toLocation, 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, 8 /* ErrorTypes.NAVIGATION_CANCELLED */)) { |
|
routerHistory.go(-info.delta, false); |
|
} |
|
else if (info.type === NavigationType.pop && |
|
isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 16 /* 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, 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(); |
|
let ready; |
|
/** |
|
* 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, to, from) { |
|
markAsReady(error); |
|
const list = errorListeners.list(); |
|
if (list.length) { |
|
list.forEach(handler => handler(error, to, from)); |
|
} |
|
else { |
|
console.error(error); |
|
} |
|
// reject the error no matter there were error listeners or not |
|
return Promise.reject(error); |
|
} |
|
function isReady() { |
|
if (ready && currentRoute.value !== START_LOCATION_NORMALIZED) |
|
return Promise.resolve(); |
|
return new Promise((resolve, reject) => { |
|
readyHandlers.add([resolve, reject]); |
|
}); |
|
} |
|
function markAsReady(err) { |
|
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, from, isPush, isFirstNavigation) { |
|
const { scrollBehavior } = options; |
|
if (!isBrowser || !scrollBehavior) |
|
return Promise.resolve(); |
|
const scrollPosition = (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) || |
|
((isFirstNavigation || !isPush) && |
|
history.state && |
|
history.state.scroll) || |
|
null; |
|
return vue.nextTick() |
|
.then(() => scrollBehavior(to, from, scrollPosition)) |
|
.then(position => position && scrollToPosition(position)) |
|
.catch(err => triggerError(err, to, from)); |
|
} |
|
const go = (delta) => routerHistory.go(delta); |
|
let started; |
|
const installedApps = new Set(); |
|
const router = { |
|
currentRoute, |
|
listening: true, |
|
addRoute, |
|
removeRoute, |
|
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) { |
|
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: () => vue.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 => { |
|
}); |
|
} |
|
const reactiveRoute = {}; |
|
for (const key in START_LOCATION_NORMALIZED) { |
|
Object.defineProperty(reactiveRoute, key, { |
|
get: () => currentRoute.value[key], |
|
enumerable: true, |
|
}); |
|
} |
|
app.provide(routerKey, router); |
|
app.provide(routeLocationKey, vue.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: type this as NavigationGuardReturn or similar instead of any |
|
function runGuardQueue(guards) { |
|
return guards.reduce((promise, guard) => promise.then(() => runWithContext(guard)), Promise.resolve()); |
|
} |
|
return router; |
|
} |
|
function extractChangingRecords(to, from) { |
|
const leavingRecords = []; |
|
const updatingRecords = []; |
|
const enteringRecords = []; |
|
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]; |
|
} |
|
|
|
/** |
|
* Returns the router instance. Equivalent to using `$router` inside |
|
* templates. |
|
*/ |
|
function useRouter() { |
|
return vue.inject(routerKey); |
|
} |
|
/** |
|
* Returns the current route location. Equivalent to using `$route` inside |
|
* templates. |
|
*/ |
|
function useRoute() { |
|
return vue.inject(routeLocationKey); |
|
} |
|
|
|
exports.RouterLink = RouterLink; |
|
exports.RouterView = RouterView; |
|
exports.START_LOCATION = START_LOCATION_NORMALIZED; |
|
exports.createMemoryHistory = createMemoryHistory; |
|
exports.createRouter = createRouter; |
|
exports.createRouterMatcher = createRouterMatcher; |
|
exports.createWebHashHistory = createWebHashHistory; |
|
exports.createWebHistory = createWebHistory; |
|
exports.isNavigationFailure = isNavigationFailure; |
|
exports.loadRouteLocation = loadRouteLocation; |
|
exports.matchedRouteKey = matchedRouteKey; |
|
exports.onBeforeRouteLeave = onBeforeRouteLeave; |
|
exports.onBeforeRouteUpdate = onBeforeRouteUpdate; |
|
exports.parseQuery = parseQuery; |
|
exports.routeLocationKey = routeLocationKey; |
|
exports.routerKey = routerKey; |
|
exports.routerViewLocationKey = routerViewLocationKey; |
|
exports.stringifyQuery = stringifyQuery; |
|
exports.useLink = useLink; |
|
exports.useRoute = useRoute; |
|
exports.useRouter = useRouter; |
|
exports.viewDepthKey = viewDepthKey;
|
|
|