diff --git a/package.json b/package.json index 632ee8c..a6d095a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@bytedo/vue-router", "description": "vue-router去除`@vue/devtools-api`依赖版", - "version": "4.1.6", + "version": "4.5.0", "type": "module", "main": "dist/vue-router.js", "scripts": { @@ -16,8 +16,8 @@ "vue" ], "devDependencies": { - "esbuild":"^0.17.8", - "iofs":"^1.5.3" + "esbuild": "^0.17.8", + "iofs": "^1.5.3" }, "author": "Yutent " } \ No newline at end of file diff --git a/src/vue-router.js b/src/vue-router.js index 05ed478..916f7aa 100644 --- a/src/vue-router.js +++ b/src/vue-router.js @@ -1,15 +1,31 @@ /*! - * vue-router v4.1.6 - * (c) 2022 Eduardo San Martin Morote + * vue-router v4.5.0 + * (c) 2024 Eduardo San Martin Morote * @license MIT */ -import { getCurrentInstance, inject, onUnmounted, onDeactivated, onActivated, computed, unref, watchEffect, defineComponent, reactive, h, provide, ref, watch, shallowRef, nextTick } from 'vue'; +import { getCurrentInstance, inject, onUnmounted, onDeactivated, onActivated, computed, unref, watchEffect, defineComponent, reactive, h, provide, ref, watch, shallowRef, shallowReactive, nextTick } from 'vue'; -const isBrowser = typeof window !== 'undefined'; +const isBrowser = typeof document !== 'undefined'; +/** + * 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); +} function isESModule(obj) { - return obj.__esModule || obj[Symbol.toStringTag] === 'Module'; + return (obj.__esModule || + obj[Symbol.toStringTag] === 'Module' || + // support CF with dynamic imports that do not + // add the Module string tag + (obj.default && isRouteComponent(obj.default))); } const assign = Object.assign; function applyToParams(fn, params) { @@ -35,6 +51,144 @@ function warn(msg) { console.warn.apply(console, ['[Vue Router warn]: ' + msg].concat(args)); } +/** + * 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) { + warn(`Error decoding "${text}". Using original value`); + } + return '' + text; +} + const TRAILING_SLASH_RE = /\/$/; const removeTrailingSlash = (path) => path.replace(TRAILING_SLASH_RE, ''); /** @@ -73,7 +227,7 @@ function parseURL(parseQuery, location, currentLocation = '/') { fullPath: path + (searchString && '?') + searchString + hash, path, query, - hash, + hash: decode(hash), }; } /** @@ -103,6 +257,7 @@ function stripBase(pathname, base) { * 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} */ @@ -174,6 +329,12 @@ function resolveRelativePath(to, from) { 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; @@ -195,11 +356,35 @@ function resolveRelativePath(to, from) { } 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('/')); + toSegments.slice(toPosition).join('/')); } +/** + * 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: '/', + // TODO: could we use a symbol in the future? + name: undefined, + params: {}, + query: {}, + hash: '', + fullPath: '/', + matched: [], + meta: {}, + redirectedFrom: undefined, +}; var NavigationType; (function (NavigationType) { @@ -261,8 +446,8 @@ function getElementPosition(el, offset) { }; } const computeScrollPosition = () => ({ - left: window.pageXOffset, - top: window.pageYOffset, + left: window.scrollX, + top: window.scrollY, }); function scrollToPosition(position) { let scrollToOptions; @@ -324,7 +509,7 @@ function scrollToPosition(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); + window.scrollTo(scrollToOptions.left != null ? scrollToOptions.left : window.scrollX, scrollToOptions.top != null ? scrollToOptions.top : window.scrollY); } } function getScrollKey(path, delta) { @@ -360,7 +545,8 @@ function getSavedScrollPosition(key) { let createBaseLocation = () => location.protocol + '//' + location.host; /** * Creates a normalized history location from a window.location object - * @param location - + * @param base - The base path + * @param location - The window.location object */ function createCurrentLocation(base, location) { const { pathname, search, hash } = location; @@ -403,7 +589,6 @@ function useHistoryListeners(base, historyState, currentLocation, replace) { else { replace(to); } - // console.log({ deltaFromCurrent }) // 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 @@ -450,7 +635,11 @@ function useHistoryListeners(base, historyState, currentLocation, replace) { } // set up the listeners and prepare teardown callbacks window.addEventListener('popstate', popStateHandler); - window.addEventListener('beforeunload', beforeUnloadListener); + // 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, @@ -542,7 +731,7 @@ function useHistoryStateNavigation(base) { if (!history.state) { warn(`history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` + `history.replaceState(history.state, '', url)\n\n` + - `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`); + `You can find more information at https://router.vuejs.org/guide/migration/#Usage-of-history-state`); } changeLocation(currentState.current, currentState, true); const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data); @@ -602,15 +791,11 @@ function createMemoryHistory(base = '') { base = normalizeBase(base); function setLocation(location) { position++; - if (position === queue.length) { - // we are at the end, we can simply append a new entry - queue.push(location); - } - else { + if (position !== queue.length) { // we are in the middle, we remove everything from here in the queue queue.splice(position); - queue.push(location); } + queue.push(location); } function triggerListeners(to, from, { direction, delta }) { const info = { @@ -718,33 +903,6 @@ 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('navigation failure' ); /** * Enumeration with all possible types for navigation failures. Can be passed to @@ -788,6 +946,12 @@ const ErrorTypeMessages = { return `Avoided redundant navigation to current location: "${from.fullPath}".`; }, }; +/** + * Creates a typed NavigationFailure object. + * @internal + * @param type - NavigationFailureType + * @param params - { from, to } + */ function createRouterError(type, params) { // keep full error messages in cjs versions { @@ -806,7 +970,7 @@ const propertiesToLog = ['params', 'query', 'hash']; function stringifyRoute(to) { if (typeof to === 'string') return to; - if ('path' in to) + if (to.path != null) return to.path; const location = {}; for (const key of propertiesToLog) { @@ -917,7 +1081,7 @@ function tokensToParser(segments, extraOptions) { if (options.end) pattern += '$'; // allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else - else if (options.strict) + else if (options.strict && !pattern.endsWith('/')) pattern += '(?:/|$)'; const re = new RegExp(pattern, options.sensitive ? '' : 'i'); function parse(path) { @@ -1264,13 +1428,14 @@ function createRouterMatcher(routes, globalOptions) { mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record; const options = mergeOptions(globalOptions, record); // generate an array of records to correctly handle aliases - const normalizedRecords = [ - mainNormalizedRecord, - ]; + 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, { + normalizedRecords.push( + // we need to normalize again to ensure the `mods` property + // being non enumerable + normalizeRouteRecord(assign({}, mainNormalizedRecord, { // this allows us to hold a copy of the `components` option // so that async components cache is hold on the original record components: originalRecord @@ -1283,7 +1448,7 @@ function createRouterMatcher(routes, globalOptions) { : mainNormalizedRecord, // the aliases are always of the same kind as the original since they // are defined on the same record - })); + }))); } } let matcher; @@ -1301,7 +1466,7 @@ function createRouterMatcher(routes, globalOptions) { } if (normalizedRecord.path === '*') { throw new Error('Catch all routes ("*") must now be defined using a param with a custom regexp.\n' + - 'See more at https://next.router.vuejs.org/guide/migration/#removed-star-or-catch-all-routes.'); + 'See more at https://router.vuejs.org/guide/migration/#Removed-star-or-catch-all-routes.'); } // create the object beforehand, so it can be passed to children matcher = createRouteRecordMatcher(normalizedRecord, parent, options); @@ -1322,8 +1487,17 @@ function createRouterMatcher(routes, globalOptions) { originalMatcher.alias.push(matcher); // remove the route if named and only for the top record (avoid in nested calls) // this works because the original record is the first one - if (isRootAdd && record.name && !isAliasRecord(matcher)) + if (isRootAdd && record.name && !isAliasRecord(matcher)) { + { + checkSameNameAsAncestor(record, parent); + } removeRoute(record.name); + } + } + // Avoid adding a record that doesn't display anything. This allows passing through records without a component to + // not be reached and pass through the catch all route + if (isMatchable(matcher)) { + insertMatcher(matcher); } if (mainNormalizedRecord.children) { const children = mainNormalizedRecord.children; @@ -1338,14 +1512,6 @@ function createRouterMatcher(routes, globalOptions) { // 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 ? () => { @@ -1379,15 +1545,8 @@ function createRouterMatcher(routes, globalOptions) { 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); + const index = findInsertionIndex(matcher, matchers); + matchers.splice(index, 0, matcher); // only add the original record to the name map if (matcher.record.name && !isAliasRecord(matcher)) matcherMap.set(matcher.record.name, matcher); @@ -1415,8 +1574,11 @@ function createRouterMatcher(routes, globalOptions) { // 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)), + // only keep optional params coming from a parent record + matcher.keys + .filter(k => !k.optional) + .concat(matcher.parent ? matcher.parent.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 && @@ -1424,12 +1586,12 @@ function createRouterMatcher(routes, globalOptions) { // throws if cannot be stringified path = matcher.stringify(params); } - else if ('path' in location) { + else if (location.path != null) { // 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; if (!path.startsWith('/')) { - warn(`The Matcher cannot resolve relative paths but received "${path}". Unless you directly called \`matcher.resolve("${path}")\`, this is probably a bug in vue-router. Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/router.`); + warn(`The Matcher cannot resolve relative paths but received "${path}". Unless you directly called \`matcher.resolve("${path}")\`, this is probably a bug in vue-router. Please open an issue at https://github.com/vuejs/router/issues/new/choose.`); } matcher = matchers.find(m => m.re.test(path)); // matcher should have a value after the loop @@ -1473,7 +1635,18 @@ function createRouterMatcher(routes, globalOptions) { } // add initial routes routes.forEach(route => addRoute(route)); - return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }; + function clearRoutes() { + matchers.length = 0; + matcherMap.clear(); + } + return { + addRoute, + resolve, + removeRoute, + clearRoutes, + getRoutes, + getRecordMatcher, + }; } function paramsFromLocation(params, keys) { const newParams = {}; @@ -1490,12 +1663,12 @@ function paramsFromLocation(params, keys) { * @returns the normalized version */ function normalizeRouteRecord(record) { - return { + const normalized = { path: record.path, redirect: record.redirect, name: record.name, meta: record.meta || {}, - aliasOf: undefined, + aliasOf: record.aliasOf, beforeEnter: record.beforeEnter, props: normalizeRecordProps(record), children: record.children || [], @@ -1503,10 +1676,19 @@ function normalizeRouteRecord(record) { leaveGuards: new Set(), updateGuards: new Set(), enterCallbacks: {}, + // must be declared afterwards + // mods: {}, components: 'components' in record ? record.components || null : record.component && { default: record.component }, }; + // mods contain modules and shouldn't be copied, + // logged or anything. It's just used for internal + // advanced use cases like data loaders + Object.defineProperty(normalized, 'mods', { + value: {}, + }); + return normalized; } /** * Normalize the optional `props` in a record to always be an object similar to @@ -1524,7 +1706,7 @@ function normalizeRecordProps(record) { // 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 === 'boolean' ? props : props[name]; + propsObject[name] = typeof props === 'object' ? props[name] : props; } return propsObject; } @@ -1590,150 +1772,74 @@ function checkChildMissingNameWithEmptyPath(mainNormalizedRecord, parent) { warn(`The route named "${String(parent.record.name)}" has a child without a name and an empty path. Using that name won't render the empty path child so you probably want to move the name to the child instead. If this is intentional, add a name to the child route to remove the warning.`); } } +function checkSameNameAsAncestor(record, parent) { + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor.record.name === record.name) { + throw new Error(`A route named "${String(record.name)}" has been added as a ${parent === ancestor ? 'child' : 'descendant'} of a route with the same name. Route names must be unique and a nested route cannot use the same name as an ancestor.`); + } + } +} function checkMissingParamsInAbsolutePath(record, parent) { for (const key of parent.keys) { if (!record.keys.find(isSameParam.bind(null, key))) return warn(`Absolute path "${record.record.path}" must have the exact same param named "${key.name}" as its parent "${parent.record.path}".`); } } -function isRecordChildOf(record, parent) { - return parent.children.some(child => child === record || isRecordChildOf(record, child)); -} - /** - * Encoding Rules ␣ = Space Path: ␣ " < > # ? { } Query: ␣ " < > # & = Hash: ␣ " - * < > ` + * Performs a binary search to find the correct insertion index for a new matcher. * - * 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. + * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships, + * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes. * - * Resources: - * - https://url.spec.whatwg.org/#urlencoded-parsing - * - https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20 + * @param matcher - new matcher to be inserted + * @param matchers - existing matchers */ -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); +function findInsertionIndex(matcher, matchers) { + // First phase: binary search based on score + let lower = 0; + let upper = matchers.length; + while (lower !== upper) { + const mid = (lower + upper) >> 1; + const sortOrder = comparePathParserScore(matcher, matchers[mid]); + if (sortOrder < 0) { + upper = mid; + } + else { + lower = mid + 1; + } } - catch (err) { - warn(`Error decoding "${text}". Using original value`); + // Second phase: check for an ancestor with the same score + const insertionAncestor = getInsertionAncestor(matcher); + if (insertionAncestor) { + upper = matchers.lastIndexOf(insertionAncestor, upper - 1); + if (upper < 0) { + // This should never happen + warn(`Finding ancestor route "${insertionAncestor.record.path}" failed for "${matcher.record.path}"`); + } } - return '' + text; + return upper; +} +function getInsertionAncestor(matcher) { + let ancestor = matcher; + while ((ancestor = ancestor.parent)) { + if (isMatchable(ancestor) && + comparePathParserScore(matcher, ancestor) === 0) { + return ancestor; + } + } + return; +} +/** + * Checks if a matcher can be reachable. This means if it's possible to reach it as a route. For example, routes without + * a component, or name, or redirect, are just used to group other routes. + * @param matcher + * @param matcher.record record of the matcher + * @returns + */ +function isMatchable({ record }) { + return !!(record.name || + (record.components && Object.keys(record.components).length) || + record.redirect); } /** @@ -1890,7 +1996,7 @@ function useCallbacks() { } return { add, - list: () => handlers, + list: () => handlers.slice(), reset, }; } @@ -1948,7 +2054,7 @@ function onBeforeRouteUpdate(updateGuard) { } registerGuard(activeRecord, 'updateGuards', updateGuard); } -function guardToPromiseFn(guard, to, from, record, name) { +function guardToPromiseFn(guard, to, from, record, name, runWithContext = fn => fn()) { // 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 @@ -1981,7 +2087,7 @@ function guardToPromiseFn(guard, to, from, record, name) { } }; // wrapping with Promise.resolve allows it to work with both async and sync guards - const guardReturn = guard.call(record && record.instances[name], to, from, canOnlyBeCalledOnce(next, to, from) ); + const guardReturn = runWithContext(() => guard.call(record && record.instances[name], to, from, canOnlyBeCalledOnce(next, to, from) )); let guardCall = Promise.resolve(guardReturn); if (guard.length < 3) guardCall = guardCall.then(next); @@ -2020,7 +2126,7 @@ function canOnlyBeCalledOnce(next, to, from) { next.apply(null, arguments); }; } -function extractComponentsGuards(matched, guardType, to, from) { +function extractComponentsGuards(matched, guardType, to, from, runWithContext = fn => fn()) { const guards = []; for (const record of matched) { if (!record.components && !record.children.length) { @@ -2067,7 +2173,8 @@ function extractComponentsGuards(matched, guardType, to, from) { // __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)); + guard && + guards.push(guardToPromiseFn(guard, to, from, record, name, runWithContext)); } else { // start requesting the chunk already @@ -2078,35 +2185,26 @@ function extractComponentsGuards(matched, guardType, to, from) { } guards.push(() => componentPromise.then(resolved => { if (!resolved) - return Promise.reject(new Error(`Couldn't resolve component "${name}" at "${record.path}"`)); + throw new Error(`Couldn't resolve component "${name}" at "${record.path}"`); const resolvedComponent = isESModule(resolved) ? resolved.default : resolved; + // keep the resolved module for plugins like data loaders + record.mods[name] = 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 (guard && + guardToPromiseFn(guard, to, from, record, name, runWithContext)()); })); } } } 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 ``. * @@ -2126,6 +2224,8 @@ function loadRouteLocation(route) { const resolvedComponent = isESModule(resolved) ? resolved.default : resolved; + // keep the resolved module for plugins like data loaders + record.mods[name] = resolved; // replace the function with the resolved component // cannot be null or undefined because we went into the for loop record.components[name] = resolvedComponent; @@ -2138,10 +2238,32 @@ function loadRouteLocation(route) { // TODO: we could allow currentRoute as a prop to expose `isActive` and // `isExactActive` behavior should go through an RFC +/** + * Returns the internal behavior of a {@link RouterLink} without the rendering part. + * + * @param props - a `to` location and an optional `replace` flag + */ function useLink(props) { const router = inject(routerKey); const currentRoute = inject(routeLocationKey); - const route = computed(() => router.resolve(unref(props.to))); + let hasPrevious = false; + let previousTo = null; + const route = computed(() => { + const to = unref(props.to); + if ((!hasPrevious || to !== previousTo)) { + if (!isRouteLocation(to)) { + if (hasPrevious) { + warn(`Invalid value for prop "to" in useLink()\n- to:`, to, `\n- previous to:`, previousTo, `\n- props:`, props); + } + else { + warn(`Invalid value for prop "to" in useLink()\n- to:`, to, `\n- props:`, props); + } + } + previousTo = to; + hasPrevious = true; + } + return router.resolve(to); + }); const activeRecordIndex = computed(() => { const { matched } = route.value; const { length } = matched; @@ -2173,9 +2295,15 @@ function useLink(props) { isSameRouteLocationParams(currentRoute.params, route.value.params)); function navigate(e = {}) { if (guardEvent(e)) { - return router[unref(props.replace) ? 'replace' : 'push'](unref(props.to) + const p = router[unref(props.replace) ? 'replace' : 'push'](unref(props.to) // avoid uncaught errors are they are logged anyway ).catch(noop); + if (props.viewTransition && + typeof document !== 'undefined' && + 'startViewTransition' in document) { + document.startViewTransition(() => p); + } + return p; } return Promise.resolve(); } @@ -2191,6 +2319,9 @@ function useLink(props) { navigate, }; } +function preferSingleVNode(vnodes) { + return vnodes.length === 1 ? vnodes[0] : vnodes; +} const RouterLinkImpl = /*#__PURE__*/ defineComponent({ name: 'RouterLink', compatConfig: { MODE: 3 }, @@ -2223,7 +2354,7 @@ const RouterLinkImpl = /*#__PURE__*/ defineComponent({ [getLinkClass(props.exactActiveClass, options.linkExactActiveClass, 'router-link-exact-active')]: link.isExactActive, })); return () => { - const children = slots.default && slots.default(link); + const children = slots.default && preferSingleVNode(slots.default(link)); return props.custom ? children : h('a', { @@ -2427,8 +2558,11 @@ const RouterView = RouterViewImpl; function warnDeprecatedUsage() { const instance = getCurrentInstance(); const parentName = instance.parent && instance.parent.type.name; + const parentSubTreeType = instance.parent && instance.parent.subTree && instance.parent.subTree.type; if (parentName && - (parentName === 'KeepAlive' || parentName.includes('Transition'))) { + (parentName === 'KeepAlive' || parentName.includes('Transition')) && + typeof parentSubTreeType === 'object' && + parentSubTreeType.name === 'RouterView') { const comp = parentName === 'KeepAlive' ? 'keep-alive' : 'transition'; warn(` can no longer be used directly inside or .\n` + `Use slot props instead:\n\n` + @@ -2556,6 +2690,8 @@ const CYAN_400 = 0x22d3ee; const ORANGE_400 = 0xfb923c; // const GRAY_100 = 0xf4f4f5 const DARK = 0x666666; +const RED_100 = 0xfee2e2; +const RED_700 = 0xb91c1c; function formatRouteRecordForInspector(route) { const tags = []; const { record } = route; @@ -2689,7 +2825,7 @@ function createRouter(options) { const routerHistory = options.history; if (!routerHistory) throw new Error('Provide the "history" option when calling "createRouter()":' + - ' https://next.router.vuejs.org/api/#history.'); + ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history'); const beforeGuards = useCallbacks(); const beforeResolveGuards = useCallbacks(); const afterGuards = useCallbacks(); @@ -2709,6 +2845,9 @@ function createRouter(options) { let record; if (isRouteName(parentOrRoute)) { parent = matcher.getRecordMatcher(parentOrRoute); + if (!parent) { + warn(`Parent route "${String(parentOrRoute)}" not found when adding child route`, route); + } record = route; } else { @@ -2732,6 +2871,7 @@ function createRouter(options) { return !!matcher.getRecordMatcher(name); } function resolve(rawLocation, currentLocation) { + // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => { // const objectLocation = routerLocationAsObject(rawLocation) // we create a copy to modify it later currentLocation = assign({}, currentLocation || currentRoute.value); @@ -2754,16 +2894,18 @@ function createRouter(options) { href, }); } + if (!isRouteLocation(rawLocation)) { + warn(`router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, rawLocation); + return resolve({}); + } let matcherLocation; // path could be relative in object as well - if ('path' in rawLocation) { + if (rawLocation.path != null) { if ('params' in rawLocation && !('name' in rawLocation) && // @ts-expect-error: the type is never Object.keys(rawLocation.params).length) { - warn(`Path "${ - // @ts-expect-error: the type is never - rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`); + warn(`Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`); } matcherLocation = assign({}, rawLocation, { path: parseURL(parseQuery$1, rawLocation.path, currentLocation.path).path, @@ -2779,7 +2921,7 @@ function createRouter(options) { } // pass encoded values to the matcher, so it can produce encoded path and fullPath matcherLocation = assign({}, rawLocation, { - params: encodeParams(rawLocation.params), + params: encodeParams(targetParams), }); // current location params are decoded, we need to encode them in case the // matcher merges the params @@ -2803,7 +2945,7 @@ function createRouter(options) { warn(`Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`); } else if (!matchedRoute.matched.length) { - warn(`No match found for location with path "${'path' in rawLocation ? rawLocation.path : rawLocation}"`); + warn(`No match found for location with path "${rawLocation.path != null ? rawLocation.path : rawLocation}"`); } } return assign({ @@ -2859,7 +3001,7 @@ function createRouter(options) { // the router parse them again newTargetLocation.params = {}; } - if (!('path' in newTargetLocation) && + if (newTargetLocation.path == null && !('name' in newTargetLocation)) { warn(`Invalid redirect found:\n${JSON.stringify(newTargetLocation, null, 2)}\n when navigating to "${to.fullPath}". A redirect must contain a name or path. This will break in production.`); throw new Error('Invalid redirect'); @@ -2868,7 +3010,7 @@ function createRouter(options) { query: to.query, hash: to.hash, // avoid transferring params if the redirect has a path - params: 'path' in newTargetLocation ? {} : to.params, + params: newTargetLocation.path != null ? {} : to.params, }, newTargetLocation); } } @@ -2924,8 +3066,8 @@ function createRouter(options) { (redirectedFrom._count = redirectedFrom._count ? // @ts-expect-error redirectedFrom._count + 1 - : 1) > 10) { - warn(`Detected an infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow. This will break in production if not fixed.`); + : 1) > 30) { + warn(`Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.`); return Promise.reject(new Error('Infinite redirect in navigation guard')); } return pushWithRedirect( @@ -2960,6 +3102,13 @@ function createRouter(options) { 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; @@ -3000,9 +3149,9 @@ function createRouter(options) { .then(() => { // check the route beforeEnter guards = []; - for (const record of to.matched) { + for (const record of enteringRecords) { // do not trigger beforeEnter on reused views - if (record.beforeEnter && !from.matched.includes(record)) { + if (record.beforeEnter) { if (isArray(record.beforeEnter)) { for (const beforeEnter of record.beforeEnter) guards.push(guardToPromiseFn(beforeEnter, to, from)); @@ -3021,7 +3170,7 @@ function createRouter(options) { // 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 = extractComponentsGuards(enteringRecords, 'beforeRouteEnter', to, from, runWithContext); guards.push(canceledNavigationCheck); // run the queue of per route beforeEnter guards return runGuardQueue(guards); @@ -3043,8 +3192,9 @@ function createRouter(options) { function triggerAfterEach(to, from, failure) { // navigation is confirmed, call afterGuards // TODO: wrap with error handlers - for (const guard of afterGuards.list()) - guard(to, from, failure); + afterGuards + .list() + .forEach(guard => runWithContext(() => guard(to, from, failure))); } /** * - Cleans up any navigation guards @@ -3092,7 +3242,7 @@ function createRouter(options) { // there could be a redirect record in history const shouldRedirect = handleRedirectRecord(toLocation); if (shouldRedirect) { - pushWithRedirect(assign(shouldRedirect, { replace: true }), toLocation).catch(noop); + pushWithRedirect(assign(shouldRedirect, { replace: true, force: true }), toLocation).catch(noop); return; } pendingLocation = toLocation; @@ -3116,7 +3266,9 @@ function createRouter(options) { // navigation guard. // the error is already handled by router.push we just want to avoid // logging the error - pushWithRedirect(error.to, toLocation + pushWithRedirect(assign(locationAsObject(error.to), { + force: true, + }), toLocation // avoid an uncaught rejection, let push call triggerError ) .then(failure => { @@ -3164,15 +3316,16 @@ function createRouter(options) { } 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 errorHandlers = useCallbacks(); + let errorListeners = useCallbacks(); let ready; /** - * Trigger errorHandlers added via onError and throws the error as well + * 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 @@ -3181,7 +3334,7 @@ function createRouter(options) { */ function triggerError(error, to, from) { markAsReady(error); - const list = errorHandlers.list(); + const list = errorListeners.list(); if (list.length) { list.forEach(handler => handler(error, to, from)); } @@ -3191,6 +3344,7 @@ function createRouter(options) { } console.error(error); } + // reject the error no matter there were error listeners or not return Promise.reject(error); } function isReady() { @@ -3235,6 +3389,7 @@ function createRouter(options) { listening: true, addRoute, removeRoute, + clearRoutes: matcher.clearRoutes, hasRoute, getRoutes, resolve, @@ -3247,7 +3402,7 @@ function createRouter(options) { beforeEach: beforeGuards.add, beforeResolve: beforeResolveGuards.add, afterEach: afterGuards.add, - onError: errorHandlers.add, + onError: errorListeners.add, isReady, install(app) { const router = this; @@ -3274,11 +3429,13 @@ function createRouter(options) { } const reactiveRoute = {}; for (const key in START_LOCATION_NORMALIZED) { - // @ts-expect-error: the key matches - reactiveRoute[key] = computed(() => currentRoute.value[key]); + Object.defineProperty(reactiveRoute, key, { + get: () => currentRoute.value[key], + enumerable: true, + }); } app.provide(routerKey, router); - app.provide(routeLocationKey, reactive(reactiveRoute)); + app.provide(routeLocationKey, shallowReactive(reactiveRoute)); app.provide(routerViewLocationKey, currentRoute); const unmountApp = app.unmount; installedApps.add(app); @@ -3302,11 +3459,12 @@ function createRouter(options) { } }, }; + // 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 runGuardQueue(guards) { - return guards.reduce((promise, guard) => promise.then(() => guard()), Promise.resolve()); -} function extractChangingRecords(to, from) { const leavingRecords = []; const updatingRecords = []; @@ -3342,7 +3500,7 @@ function useRouter() { * Returns the current route location. Equivalent to using `$route` inside * templates. */ -function useRoute() { +function useRoute(_name) { return inject(routeLocationKey); } diff --git a/update.js b/update.js index 9bfcbf5..8a804cf 100644 --- a/update.js +++ b/update.js @@ -78,7 +78,8 @@ export function execAsync(cmd) { outdir: 'dist', target: 'es2017', format: 'esm', - minify: true + minify: true, + treeShaking: true }) fs.rm('./package', true)