From e89a2ab7d14051ad2b48a14dc40c8de7d3216807 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 19:06:37 +0200 Subject: [PATCH 01/71] feat: add navigation-api router --- package.json | 1 + packages/router/src/index.ts | 2 + packages/router/src/navigation-api/index.ts | 730 ++++++++++++++++++++ packages/router/src/router-factory.ts | 1 + packages/router/tsconfig.json | 3 +- pnpm-lock.yaml | 8 + 6 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 packages/router/src/navigation-api/index.ts create mode 100644 packages/router/src/router-factory.ts diff --git a/package.json b/package.json index 83c776139..0332ae857 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "postinstall": "simple-git-hooks" }, "devDependencies": { + "@types/dom-navigation": "^1.0.6", "@vitest/coverage-v8": "^2.1.9", "@vitest/ui": "^2.1.9", "brotli": "^1.3.3", diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 2b27d8329..b816fb54d 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -138,7 +138,9 @@ export type { } from './typed-routes' export { createRouter } from './router' +export { createNavigationApiRouter } from './navigation-api' export type { Router, RouterOptions, RouterScrollBehavior } from './router' +export type { RouterApiOptions } from './navigation-api' export { NavigationFailureType, isNavigationFailure } from './errors' export type { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts new file mode 100644 index 000000000..bdf11764d --- /dev/null +++ b/packages/router/src/navigation-api/index.ts @@ -0,0 +1,730 @@ +/// + +import { App, shallowReactive, shallowRef, unref } from 'vue' +import { + parseURL, + stringifyURL, + isSameRouteLocation, + isSameRouteRecord, + START_LOCATION_NORMALIZED, +} from '../location' + +import { + normalizeQuery, + parseQuery as originalParseQuery, + stringifyQuery as originalStringifyQuery, + LocationQuery, +} from '../query' +import type { + RouteLocationRaw, + RouteParams, + RouteLocationNormalized, + RouteLocationNormalizedLoaded, + RouteLocationResolved, + RouteRecordNameGeneric, + RouteLocation, +} from '../typed-routes' +import type { Router, RouterOptions } from '../router' +import { createRouterMatcher } from '../matcher' +import { useCallbacks } from '../utils/callbacks' +import { extractComponentsGuards, guardToPromiseFn } from '../navigationGuards' +import { + createRouterError, + ErrorTypes, + isNavigationFailure, + NavigationFailure, + NavigationRedirectError, +} from '../errors' +import { applyToParams, assign, isArray, isBrowser } from '../utils' +import { warn } from '../warning' +import { decode, encodeHash, encodeParam } from '../encoding' +import { + isRouteLocation, + isRouteName, + Lazy, + MatcherLocationRaw, + RouteLocationOptions, + RouteRecordRaw, +} from '../types' +import { RouterLink } from '../RouterLink' +import { RouterView } from '../RouterView' +import { + routeLocationKey, + routerKey, + routerViewLocationKey, +} from '../injectionSymbols' +import { RouterHistory } from '../history/common' +import { RouteRecordNormalized } from '../matcher/types' + +export interface RouterApiOptions extends Omit { + base?: string + location: string +} + +export function createNavigationApiRouter(options: RouterApiOptions): Router { + const matcher = createRouterMatcher(options.routes, options) + const parseQuery = options.parseQuery || originalParseQuery + const stringifyQuery = options.stringifyQuery || originalStringifyQuery + + const beforeGuards = useCallbacks() + const beforeResolveGuards = useCallbacks() + const afterGuards = useCallbacks() + const errorListeners = useCallbacks() + + const currentRoute = shallowRef( + START_LOCATION_NORMALIZED + ) + + let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED + + let started: boolean | undefined + const installedApps = new Set() + + function runWithContext(fn: () => T): T { + const app: App | undefined = installedApps.values().next().value + // support Vue < 3.3 + return app && typeof app.runWithContext === 'function' + ? app.runWithContext(fn) + : fn() + } + + function runGuardQueue(guards: Lazy[]): Promise { + return guards.reduce( + (promise, guard) => promise.then(() => runWithContext(guard)), + Promise.resolve() + ) + } + + let ready: boolean = false + const readyHandlers = useCallbacks<[(v: any) => void, (e: any) => void]>() + + async function resolveNavigationGuards( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + const [leavingRecords, updatingRecords, enteringRecords] = + extractChangingRecords(to, from) + + let guards = extractComponentsGuards( + leavingRecords.reverse(), + 'beforeRouteLeave', + to, + from + ) + await runGuardQueue(guards) + + guards = [] + for (const guard of beforeGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + await runGuardQueue(guards) + + guards = extractComponentsGuards( + updatingRecords, + 'beforeRouteUpdate', + to, + from + ) + await runGuardQueue(guards) + + guards = [] + for (const record of enteringRecords) { + 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)) + } + } + } + await runGuardQueue(guards) + + // Resolve async components and run beforeRouteEnter + guards = extractComponentsGuards( + enteringRecords, + 'beforeRouteEnter', + to, + from + ) + await runGuardQueue(guards) + + guards = [] + for (const guard of beforeResolveGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + await runGuardQueue(guards) + } + + function finalizeNavigation( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded, + failure?: NavigationFailure + ) { + currentRoute.value = to as RouteLocationNormalizedLoaded + markAsReady() // Marcamos como listo en la primera navegación exitosa + afterGuards.list().forEach(guard => guard(to, from, failure)) + } + + function markAsReady(err?: any): void { + if (!ready) { + ready = !err + // @ts-expect-error we need to add some types + readyHandlers + .list() + .forEach(([resolve, reject]) => (err ? reject(err) : resolve())) + readyHandlers.reset() + } + } + + function isReady(): Promise { + if (ready && currentRoute.value !== START_LOCATION_NORMALIZED) + return Promise.resolve() + return new Promise((resolve, reject) => { + readyHandlers.add([resolve, reject]) + }) + } + + function triggerError( + error: any, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + markAsReady(error) + const list = errorListeners.list() + if (list.length) { + list.forEach(handler => handler(error, to, from)) + } else { + console.error('uncaught error during route navigation:') + console.error(error) + } + return Promise.reject(error) + } + + function go(delta: number) { + // Case 1: go(0) should trigger a reload. + if (delta === 0) { + window.navigation.reload() + return + } + + // Get the current state safely, without using non-null assertions ('!'). + const entries = window.navigation.entries() + const currentIndex = window.navigation.currentEntry?.index + + // If we don't have a current index, we can't proceed. + if (currentIndex === undefined) { + return + } + + // Calculate the target index in the history stack. + const targetIndex = currentIndex + delta + + // Validate that the target index is within the bounds of the entries array. + // This is the key check that prevents runtime errors. + if (targetIndex >= 0 && targetIndex < entries.length) { + // Each history entry has a unique 'key'. We get the key for our target entry... + // Safely get the target entry from the array. + const targetEntry = entries[targetIndex] + + // Add a check to ensure the entry is not undefined before accessing its key. + // This satisfies TypeScript's strict checks. + if (targetEntry) { + window.navigation.traverseTo(targetEntry.key) + } else { + // This case is unlikely if the index check passed, but it adds robustness. + console.warn( + `go(${delta}) failed: No entry found at index ${targetIndex}.` + ) + } + } else { + console.warn( + `go(${delta}) failed: target index ${targetIndex} is out of bounds.` + ) + } + } + + function locationAsObject( + to: RouteLocationRaw | RouteLocationNormalized + ): Exclude | RouteLocationNormalized { + return typeof to === 'string' + ? parseURL(parseQuery, to, currentRoute.value.path) + : assign({}, to) + } + + function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void { + const lastMatched = to.matched[to.matched.length - 1] + if (lastMatched && lastMatched.redirect) { + const { redirect } = lastMatched + let newTargetLocation = + typeof redirect === 'function' ? redirect(to) : redirect + + if (typeof newTargetLocation === 'string') { + newTargetLocation = + newTargetLocation.includes('?') || newTargetLocation.includes('#') + ? (newTargetLocation = locationAsObject(newTargetLocation)) + : // force empty params + { path: newTargetLocation } + // @ts-expect-error: force empty params when a string is passed to let + // the router parse them again + newTargetLocation.params = {} + } + + if ( + __DEV__ && + newTargetLocation.path == null && + !('name' in newTargetLocation) + ) { + warn( + `Invalid redirect found:\n${JSON.stringify( + newTargetLocation, + null, + 2 + )}\n when navigating to "${ + to.fullPath + }". A redirect must contain a name or path. This will break in production.` + ) + throw new Error('Invalid redirect') + } + + return assign( + { + query: to.query, + hash: to.hash, + // avoid transferring params if the redirect has a path + params: newTargetLocation.path != null ? {} : to.params, + }, + newTargetLocation + ) + } + } + + async function navigate( + to: RouteLocationRaw, + replace?: boolean + ): Promise { + const toLocation = resolve(to) + const from = currentRoute.value + + const redirect = handleRedirectRecord(toLocation) + if (redirect) { + return navigate( + assign(typeof redirect === 'string' ? { path: redirect } : redirect, { + replace, + }), + true + ) + } + + pendingLocation = toLocation as RouteLocationNormalized + + if ( + !(to as RouteLocationOptions).force && + isSameRouteLocation(stringifyQuery, from, toLocation) + ) { + const failure = createRouterError( + ErrorTypes.NAVIGATION_DUPLICATED, + { + to: toLocation as RouteLocationNormalized, + from, + } + ) + finalizeNavigation(from, from, failure) + return failure + } + + pendingLocation = toLocation as RouteLocationNormalized + + const navOptions: NavigationNavigateOptions = { + state: (to as RouteLocationOptions).state, + } + if (replace) { + navOptions.history = 'replace' + } + window.navigation.navigate(toLocation.href, navOptions) + } + + const normalizeParams = applyToParams.bind( + null, + paramValue => '' + paramValue + ) + const encodeParams = applyToParams.bind(null, encodeParam) + const decodeParams: (params: RouteParams | undefined) => RouteParams = + // @ts-expect-error: intentionally avoid the type check + applyToParams.bind(null, decode) + + function addRoute( + parentOrRoute: NonNullable | RouteRecordRaw, + route?: RouteRecordRaw + ) { + let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined + let record: RouteRecordRaw + if (isRouteName(parentOrRoute)) { + parent = matcher.getRecordMatcher(parentOrRoute) + if (__DEV__ && !parent) { + warn( + `Parent route "${String( + parentOrRoute + )}" not found when adding child route`, + route + ) + } + record = route! + } else { + record = parentOrRoute + } + + return matcher.addRoute(record, parent) + } + + function removeRoute(name: NonNullable) { + const recordMatcher = matcher.getRecordMatcher(name) + if (recordMatcher) { + matcher.removeRoute(recordMatcher) + } else if (__DEV__) { + warn(`Cannot remove non-existent route "${String(name)}"`) + } + } + + function getRoutes() { + return matcher.getRoutes().map(routeMatcher => routeMatcher.record) + } + + function hasRoute(name: NonNullable): boolean { + return !!matcher.getRecordMatcher(name) + } + + const BEFORE_HASH_RE = /^[^#]+#/ + function createHref(base: string, location: string): string { + return base.replace(BEFORE_HASH_RE, '#') + location + } + + function resolve( + rawLocation: RouteLocationRaw, + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved { + // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => { + // const objectLocation = routerLocationAsObject(rawLocation) + // we create a copy to modify it later + currentLocation = assign({}, currentLocation || currentRoute.value) + if (typeof rawLocation === 'string') { + const locationNormalized = parseURL( + parseQuery, + rawLocation, + currentLocation.path + ) + const matchedRoute = matcher.resolve( + { path: locationNormalized.path }, + currentLocation + ) + + const href = createHref(locationNormalized.fullPath, options.base || '/') + if (__DEV__) { + if (href.startsWith('//')) + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + else if (!matchedRoute.matched.length) { + warn(`No match found for location with path "${rawLocation}"`) + } + } + + // locationNormalized is always a new object + return assign(locationNormalized, matchedRoute, { + params: decodeParams(matchedRoute.params), + hash: decode(locationNormalized.hash), + redirectedFrom: undefined, + href, + }) + } + + if (__DEV__ && !isRouteLocation(rawLocation)) { + warn( + `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, + rawLocation + ) + return resolve({}) + } + + let matcherLocation: MatcherLocationRaw + + // path could be relative in object as well + if (rawLocation.path != null) { + if ( + __DEV__ && + 'params' in rawLocation && + !('name' in rawLocation) && + // @ts-expect-error: the type is never + Object.keys(rawLocation.params).length + ) { + warn( + `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.` + ) + } + matcherLocation = assign({}, rawLocation, { + path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path, + }) + } else { + // remove any nullish param + const targetParams = assign({}, rawLocation.params) + for (const key in targetParams) { + if (targetParams[key] == null) { + delete targetParams[key] + } + } + // pass encoded values to the matcher, so it can produce encoded path and fullPath + matcherLocation = assign({}, rawLocation, { + params: encodeParams(targetParams), + }) + // current location params are decoded, we need to encode them in case the + // matcher merges the params + currentLocation.params = encodeParams(currentLocation.params) + } + + const matchedRoute = matcher.resolve(matcherLocation, currentLocation) + const hash = rawLocation.hash || '' + + if (__DEV__ && hash && !hash.startsWith('#')) { + warn( + `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` + ) + } + + // the matcher might have merged current location params, so + // we need to run the decoding again + matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params)) + + const fullPath = stringifyURL( + stringifyQuery, + assign({}, rawLocation, { + hash: encodeHash(hash), + path: matchedRoute.path, + }) + ) + + const href = createHref(fullPath, options.base || '/') + if (__DEV__) { + if (href.startsWith('//')) { + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + } else if (!matchedRoute.matched.length) { + warn( + `No match found for location with path "${ + rawLocation.path != null ? rawLocation.path : rawLocation + }"` + ) + } + } + + return assign( + { + fullPath, + // keep the hash encoded so fullPath is effectively path + encodedQuery + + // hash + hash, + query: + // if the user is using a custom query lib like qs, we might have + // nested objects, so we keep the query as is, meaning it can contain + // numbers at `$route.query`, but at the point, the user will have to + // use their own type anyway. + // https://github.com/vuejs/router/issues/328#issuecomment-649481567 + stringifyQuery === originalStringifyQuery + ? normalizeQuery(rawLocation.query) + : ((rawLocation.query || {}) as LocationQuery), + }, + matchedRoute, + { + redirectedFrom: undefined, + href, + } + ) + } + + function handleNavigate(event: NavigateEvent) { + if (!event.canIntercept) return + + event.intercept({ + async handler() { + const to = resolve(event.destination.url) as RouteLocationNormalized + const from = currentRoute.value + pendingLocation = to + await resolveNavigationGuards(to, from) + }, + }) + } + + function handleNavigateSuccess(event: Event) { + if (pendingLocation !== START_LOCATION_NORMALIZED) { + finalizeNavigation( + resolve(pendingLocation) as RouteLocationNormalized, + currentRoute.value + ) + pendingLocation = START_LOCATION_NORMALIZED + } + } + + function handleNavigateError(event: ErrorEvent) { + const failure = event.error as NavigationFailure + + if (pendingLocation !== START_LOCATION_NORMALIZED) { + finalizeNavigation( + pendingLocation as RouteLocationNormalized, + currentRoute.value, + failure + ) + + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { + navigate((failure as NavigationRedirectError).to, true) + } else { + triggerError( + failure, + pendingLocation as RouteLocationNormalized, + currentRoute.value + ) + } + pendingLocation = START_LOCATION_NORMALIZED + } + } + + window.navigation.addEventListener('navigate', handleNavigate) + window.navigation.addEventListener('navigatesuccess', handleNavigateSuccess) + window.navigation.addEventListener('navigateerror', handleNavigateError) + + function destroy() { + window.navigation.removeEventListener('navigate', handleNavigate) + window.navigation.removeEventListener( + 'navigatesuccess', + handleNavigateSuccess + ) + window.navigation.removeEventListener('navigateerror', handleNavigateError) + } + + const history: RouterHistory = { + base: options.base || '/', + location: options.location, + state: undefined!, + createHref: createHref.bind(null, options.base || '/'), + destroy, + go, + listen(): () => void { + throw new Error('unsupported operation') + }, + push: (to: RouteLocationRaw) => navigate(to, false), + replace: (to: RouteLocationRaw) => navigate(to, true), + } + + const router: Router = { + currentRoute, + listening: true, + + addRoute, + removeRoute, + clearRoutes: matcher.clearRoutes, + hasRoute, + getRoutes, + resolve, + options: { + ...options, + history, + }, + + push: (to: RouteLocationRaw) => navigate(to, false), + replace: (to: RouteLocationRaw) => navigate(to, true), + go, + back: () => go(-1), + forward: () => go(1), + + beforeEach: beforeGuards.add, + beforeResolve: beforeResolveGuards.add, + afterEach: afterGuards.add, + + onError: errorListeners.add, + isReady, + + install(app) { + app.component('RouterLink', RouterLink) + app.component('RouterView', RouterView) + + app.config.globalProperties.$router = router + Object.defineProperty(app.config.globalProperties, '$route', { + enumerable: true, + get: () => unref(currentRoute), + }) + + // this initial navigation is only necessary on client, on server it doesn't + // make sense because it will create an extra unnecessary navigation and could + // lead to problems + if ( + isBrowser && + // used for the initial navigation client side to avoid pushing + // multiple times when the router is used in multiple apps + !started && + currentRoute.value === START_LOCATION_NORMALIZED + ) { + // see above + started = true + navigate(options.location).catch(err => { + if (__DEV__) warn('Unexpected error when starting the router:', err) + }) + } + + const reactiveRoute = {} as RouteLocationNormalizedLoaded + for (const key in START_LOCATION_NORMALIZED) { + Object.defineProperty(reactiveRoute, key, { + get: () => currentRoute.value[key as keyof RouteLocationNormalized], + enumerable: true, + }) + } + + app.provide(routerKey, router) + app.provide(routeLocationKey, shallowReactive(reactiveRoute)) + app.provide(routerViewLocationKey, currentRoute) + + const unmountApp = app.unmount + installedApps.add(app) + app.unmount = function () { + installedApps.delete(app) + // the router is not attached to an app anymore + if (installedApps.size < 1) { + // invalidate the current navigation + pendingLocation = START_LOCATION_NORMALIZED + currentRoute.value = START_LOCATION_NORMALIZED + started = false + ready = false + } + unmountApp() + } + }, + } + + return router +} + +function extractChangingRecords( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded +) { + const leavingRecords: RouteRecordNormalized[] = [] + const updatingRecords: RouteRecordNormalized[] = [] + const enteringRecords: RouteRecordNormalized[] = [] + + const len = Math.max(from.matched.length, to.matched.length) + for (let i = 0; i < len; i++) { + const recordFrom = from.matched[i] + if (recordFrom) { + if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) + updatingRecords.push(recordFrom) + else leavingRecords.push(recordFrom) + } + const recordTo = to.matched[i] + if (recordTo) { + // the type doesn't matter because we are comparing per reference + if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) { + enteringRecords.push(recordTo) + } + } + } + + return [leavingRecords, updatingRecords, enteringRecords] +} diff --git a/packages/router/src/router-factory.ts b/packages/router/src/router-factory.ts new file mode 100644 index 000000000..8f996c4bf --- /dev/null +++ b/packages/router/src/router-factory.ts @@ -0,0 +1 @@ +export function createRouterFactory() {} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json index 41fc6c378..e54beb84f 100644 --- a/packages/router/tsconfig.json +++ b/packages/router/tsconfig.json @@ -34,7 +34,8 @@ ], "types": [ "node", - "vite/client" + "vite/client", + "dom-navigation" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e238c902..222bd8adf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@types/dom-navigation': + specifier: ^1.0.6 + version: 1.0.6 '@vitest/coverage-v8': specifier: ^2.1.9 version: 2.1.9(vitest@2.1.9(@types/node@22.15.2)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@19.0.0)(terser@5.32.0)) @@ -767,6 +770,9 @@ packages: '@types/chai@4.3.16': resolution: {integrity: sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==} + '@types/dom-navigation@1.0.6': + resolution: {integrity: sha512-4srBpebg8rFDm0LafYuWhZMgLoSr5J4gx4q1uaTqOXwVk00y+CkTdJ4SC57sR1cMhP0ZRjApMRdHVcFYOvPGTw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -4319,6 +4325,8 @@ snapshots: '@types/chai@4.3.16': {} + '@types/dom-navigation@1.0.6': {} + '@types/estree@1.0.6': {} '@types/estree@1.0.7': {} From 166f7105120d702c421867f807c5c1e0963d334d Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 19:28:15 +0200 Subject: [PATCH 02/71] chore: fix build error and remove triple slash reference --- packages/router/src/navigation-api/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index bdf11764d..194a68978 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1,5 +1,3 @@ -/// - import { App, shallowReactive, shallowRef, unref } from 'vue' import { parseURL, @@ -169,9 +167,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { function markAsReady(err?: any): void { if (!ready) { ready = !err - // @ts-expect-error we need to add some types readyHandlers .list() + // @ts-expect-error we need to add some types .forEach(([resolve, reject]) => (err ? reject(err) : resolve())) readyHandlers.reset() } From 57e4f547fe0de61245c8bc1e8f02470b40e690ba Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 19:53:04 +0200 Subject: [PATCH 03/71] chore: fix createHref --- packages/router/src/navigation-api/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 194a68978..f8194076b 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -392,9 +392,13 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { return !!matcher.getRecordMatcher(name) } - const BEFORE_HASH_RE = /^[^#]+#/ - function createHref(base: string, location: string): string { - return base.replace(BEFORE_HASH_RE, '#') + location + function createHref(base: string, path: string): string { + if (path === '/') return base || '/' + return ( + (base.endsWith('/') ? base.slice(0, -1) : base) + + (path.startsWith('/') ? '' : '/') + + path + ) } function resolve( From a869c14470ac1c826d805481acea5850f4e1aead Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 20:02:49 +0200 Subject: [PATCH 04/71] chore: fix intercept handler path --- packages/router/src/navigation-api/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index f8194076b..aff32879b 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -548,7 +548,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { event.intercept({ async handler() { - const to = resolve(event.destination.url) as RouteLocationNormalized + const destination = new URL(event.destination.url) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized const from = currentRoute.value pendingLocation = to await resolveNavigationGuards(to, from) From 61bece389ac90af7ae7b7035b271c115c0f6423b Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 20:43:39 +0200 Subject: [PATCH 05/71] chore: move beforeRouteLeave guards from intercept handler to navigate --- packages/router/src/navigation-api/index.ts | 42 +++++++++++++++------ 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index aff32879b..781ca3082 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -100,18 +100,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded ): Promise { - const [leavingRecords, updatingRecords, enteringRecords] = - extractChangingRecords(to, from) + const [updatingRecords, enteringRecords] = extractChangingRecords(to, from) - let guards = extractComponentsGuards( - leavingRecords.reverse(), - 'beforeRouteLeave', - to, - from - ) - await runGuardQueue(guards) - - guards = [] + let guards: (() => Promise)[] = [] for (const guard of beforeGuards.list()) { guards.push(guardToPromiseFn(guard, to, from)) } @@ -331,6 +322,35 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { return failure } + // here the beforeRouteLeave guards are called: intercept handler can be too late to find the components + try { + const [leavingRecords] = extractChangingRecords( + toLocation as RouteLocationNormalized, + from + ) + + let guards = extractComponentsGuards( + leavingRecords.reverse(), + 'beforeRouteLeave', + toLocation as RouteLocationNormalized, + from + ) + await runGuardQueue(guards) + } catch (err) { + const failure = err as NavigationFailure + + // Comprobamos si el fallo es una REDIRECCIÓN + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { + // Si es así, iniciamos una nueva navegación con el destino de la redirección + // y devolvemos su resultado. + return navigate((failure as NavigationRedirectError).to, true) + } + + finalizeNavigation(from, from, failure) + triggerError(failure, toLocation as RouteLocationNormalized, from) + return failure + } + pendingLocation = toLocation as RouteLocationNormalized const navOptions: NavigationNavigateOptions = { From 8fd37aaa5885d14a670767f4fdcc70eb62f3f6d6 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 21:13:55 +0200 Subject: [PATCH 06/71] Revert "chore: move beforeRouteLeave guards from intercept handler to navigate" This reverts commit 61bece389ac90af7ae7b7035b271c115c0f6423b. --- packages/router/src/navigation-api/index.ts | 42 ++++++--------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 781ca3082..aff32879b 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -100,9 +100,18 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded ): Promise { - const [updatingRecords, enteringRecords] = extractChangingRecords(to, from) + const [leavingRecords, updatingRecords, enteringRecords] = + extractChangingRecords(to, from) - let guards: (() => Promise)[] = [] + let guards = extractComponentsGuards( + leavingRecords.reverse(), + 'beforeRouteLeave', + to, + from + ) + await runGuardQueue(guards) + + guards = [] for (const guard of beforeGuards.list()) { guards.push(guardToPromiseFn(guard, to, from)) } @@ -322,35 +331,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { return failure } - // here the beforeRouteLeave guards are called: intercept handler can be too late to find the components - try { - const [leavingRecords] = extractChangingRecords( - toLocation as RouteLocationNormalized, - from - ) - - let guards = extractComponentsGuards( - leavingRecords.reverse(), - 'beforeRouteLeave', - toLocation as RouteLocationNormalized, - from - ) - await runGuardQueue(guards) - } catch (err) { - const failure = err as NavigationFailure - - // Comprobamos si el fallo es una REDIRECCIÓN - if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { - // Si es así, iniciamos una nueva navegación con el destino de la redirección - // y devolvemos su resultado. - return navigate((failure as NavigationRedirectError).to, true) - } - - finalizeNavigation(from, from, failure) - triggerError(failure, toLocation as RouteLocationNormalized, from) - return failure - } - pendingLocation = toLocation as RouteLocationNormalized const navOptions: NavigationNavigateOptions = { From 68743f36902e6ec7e030aa0c80665f45cb357012 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 23:18:45 +0200 Subject: [PATCH 07/71] chore: refactor logic and navigation guards promises logic --- packages/router/package.json | 1 + packages/router/src/navigation-api/index.ts | 159 ++++++++++++++---- packages/router/src/navigationGuards.ts | 62 ++++--- .../src/typed-routes/navigation-guards.ts | 10 +- pnpm-lock.yaml | 3 + 5 files changed, 175 insertions(+), 60 deletions(-) diff --git a/packages/router/package.json b/packages/router/package.json index ba06a10be..b738bd65c 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -123,6 +123,7 @@ "@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-terser": "^0.4.4", + "@types/dom-navigation": "^1.0.6", "@types/jsdom": "^21.1.7", "@types/nightwatch": "^2.3.32", "@vitejs/plugin-vue": "^5.2.3", diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index aff32879b..8a7748221 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -51,7 +51,12 @@ import { routerKey, routerViewLocationKey, } from '../injectionSymbols' -import { RouterHistory } from '../history/common' +import { + NavigationDirection, + NavigationInformation, + NavigationType, + RouterHistory, +} from '../history/common' import { RouteRecordNormalized } from '../matcher/types' export interface RouterApiOptions extends Omit { @@ -73,7 +78,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { START_LOCATION_NORMALIZED ) - let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED + let pendingLocation: RouteLocation | undefined + let navigationInfo: NavigationInformation | undefined + let lastSuccessfulLocation: RouteLocationNormalizedLoaded = + START_LOCATION_NORMALIZED let started: boolean | undefined const installedApps = new Set() @@ -107,13 +115,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { leavingRecords.reverse(), 'beforeRouteLeave', to, - from + from, + undefined, + navigationInfo ) await runGuardQueue(guards) guards = [] for (const guard of beforeGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) + guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } await runGuardQueue(guards) @@ -130,9 +140,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { if (record.beforeEnter) { if (isArray(record.beforeEnter)) { for (const beforeEnter of record.beforeEnter) - guards.push(guardToPromiseFn(beforeEnter, to, from)) + guards.push( + guardToPromiseFn(beforeEnter, to, from, { info: navigationInfo }) + ) } else { - guards.push(guardToPromiseFn(record.beforeEnter, to, from)) + guards.push( + guardToPromiseFn(record.beforeEnter, to, from, { + info: navigationInfo, + }) + ) } } } @@ -143,13 +159,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { enteringRecords, 'beforeRouteEnter', to, - from + from, + undefined, + navigationInfo ) await runGuardQueue(guards) guards = [] for (const guard of beforeResolveGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) + guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } await runGuardQueue(guards) } @@ -159,6 +177,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { from: RouteLocationNormalizedLoaded, failure?: NavigationFailure ) { + if (!failure) { + lastSuccessfulLocation = to + } currentRoute.value = to as RouteLocationNormalizedLoaded markAsReady() // Marcamos como listo en la primera navegación exitosa afterGuards.list().forEach(guard => guard(to, from, failure)) @@ -299,19 +320,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function navigate( to: RouteLocationRaw, - replace?: boolean + options: { replace?: boolean; state?: any } = {} ): Promise { + const { replace = false, state } = options const toLocation = resolve(to) const from = currentRoute.value const redirect = handleRedirectRecord(toLocation) if (redirect) { - return navigate( - assign(typeof redirect === 'string' ? { path: redirect } : redirect, { - replace, - }), - true - ) + return navigate(assign({ replace }, redirect), { replace: true, state }) } pendingLocation = toLocation as RouteLocationNormalized @@ -546,6 +563,31 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return + if (event.navigationType === 'traverse') { + const fromIndex = window.navigation.currentEntry?.index ?? -1 + const toIndex = event.destination.index + const delta = fromIndex === -1 ? 0 : toIndex - fromIndex + + navigationInfo = { + type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. + direction: + delta > 0 ? NavigationDirection.forward : NavigationDirection.back, + delta, + } + } else if ( + event.navigationType === 'push' || + event.navigationType === 'replace' + ) { + navigationInfo = { + type: + event.navigationType === 'push' + ? NavigationType.push + : NavigationType.pop, + direction: NavigationDirection.unknown, // No specific direction for push/replace. + delta: event.navigationType === 'push' ? 1 : 0, + } + } + event.intercept({ async handler() { const destination = new URL(event.destination.url) @@ -559,45 +601,94 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { }) } + async function handleCurrentEntryChange( + event: NavigationCurrentEntryChangeEvent + ) { + if (event.navigationType !== 'traverse') { + return + } + + const to = resolve( + window.navigation.currentEntry!.url! + ) as RouteLocationNormalized + const from = lastSuccessfulLocation + + const fromIndex = event.from.index + const toIndex = window.navigation.currentEntry!.index + const delta = toIndex - fromIndex + navigationInfo = { + type: NavigationType.pop, + direction: + delta > 0 ? NavigationDirection.forward : NavigationDirection.back, + delta, + } + + pendingLocation = to + + try { + // then browser has been done the navigation, we just run the guards + await resolveNavigationGuards(to, from) + finalizeNavigation(to, from) + } catch (error) { + const failure = error as NavigationFailure + + go(fromIndex - toIndex) + + finalizeNavigation(from, to, failure) + + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { + navigate((failure as NavigationRedirectError).to, { replace: true }) + } else { + triggerError(failure, to, from) + } + } + } + function handleNavigateSuccess(event: Event) { - if (pendingLocation !== START_LOCATION_NORMALIZED) { + navigationInfo = undefined + if (pendingLocation) { finalizeNavigation( resolve(pendingLocation) as RouteLocationNormalized, currentRoute.value ) - pendingLocation = START_LOCATION_NORMALIZED + pendingLocation = undefined } } function handleNavigateError(event: ErrorEvent) { + navigationInfo = undefined + const failure = event.error as NavigationFailure - if (pendingLocation !== START_LOCATION_NORMALIZED) { - finalizeNavigation( - pendingLocation as RouteLocationNormalized, - currentRoute.value, - failure - ) + if (pendingLocation) { + const to = pendingLocation as RouteLocationNormalized + const from = currentRoute.value + pendingLocation = undefined + + finalizeNavigation(to, from, failure) if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { - navigate((failure as NavigationRedirectError).to, true) + navigate((failure as NavigationRedirectError).to, { replace: true }) } else { - triggerError( - failure, - pendingLocation as RouteLocationNormalized, - currentRoute.value - ) + triggerError(failure, to, from) } - pendingLocation = START_LOCATION_NORMALIZED } } window.navigation.addEventListener('navigate', handleNavigate) + window.navigation.addEventListener( + 'currententrychange', + handleCurrentEntryChange + ) window.navigation.addEventListener('navigatesuccess', handleNavigateSuccess) window.navigation.addEventListener('navigateerror', handleNavigateError) function destroy() { window.navigation.removeEventListener('navigate', handleNavigate) + window.navigation.removeEventListener( + 'currententrychange', + handleCurrentEntryChange + ) window.navigation.removeEventListener( 'navigatesuccess', handleNavigateSuccess @@ -615,8 +706,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { listen(): () => void { throw new Error('unsupported operation') }, - push: (to: RouteLocationRaw) => navigate(to, false), - replace: (to: RouteLocationRaw) => navigate(to, true), + push: (to: RouteLocationRaw) => navigate(to), + replace: (to: RouteLocationRaw) => navigate(to, { replace: true }), } const router: Router = { @@ -634,8 +725,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { history, }, - push: (to: RouteLocationRaw) => navigate(to, false), - replace: (to: RouteLocationRaw) => navigate(to, true), + push: (to: RouteLocationRaw) => navigate(to), + replace: (to: RouteLocationRaw) => navigate(to, { replace: true }), go, back: () => go(-1), forward: () => go(1), diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index db53c3dc1..4d7935534 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -22,6 +22,7 @@ import { matchedRouteKey } from './injectionSymbols' import { RouteRecordNormalized } from './matcher/types' import { isESModule, isRouteComponent } from './utils' import { warn } from './warning' +import { NavigationInformation } from './history/common' function registerGuard( record: RouteRecordNormalized, @@ -106,27 +107,20 @@ export function onBeforeRouteUpdate(updateGuard: NavigationGuard) { registerGuard(activeRecord, 'updateGuards', updateGuard) } -export function guardToPromiseFn( - guard: NavigationGuard, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded -): () => Promise -export function guardToPromiseFn( - guard: NavigationGuard, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded, - record: RouteRecordNormalized, - name: string, - runWithContext: (fn: () => T) => T -): () => Promise +interface GuardToPromiseFnOptions { + record?: RouteRecordNormalized + name?: string + runWithContext?: (fn: () => T) => T + info?: NavigationInformation +} + export function guardToPromiseFn( guard: NavigationGuard, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - record?: RouteRecordNormalized, - name?: string, - runWithContext: (fn: () => T) => T = fn => fn() + options: GuardToPromiseFnOptions = {} ): () => Promise { + const { record, name, runWithContext = fn => fn(), info } = options // keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place const enterCallbackArray = record && @@ -179,7 +173,7 @@ export function guardToPromiseFn( record && record.instances[name!], to, from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next + __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next ) ) let guardCall = Promise.resolve(guardReturn) @@ -214,14 +208,19 @@ export function guardToPromiseFn( function canOnlyBeCalledOnce( next: NavigationGuardNext, to: RouteLocationNormalized, - from: RouteLocationNormalized + from: RouteLocationNormalized, + info?: NavigationInformation ): NavigationGuardNext { let called = 0 return function () { - if (called++ === 1) + if (called++ === 1) { + const showInfo = info + ? ` (type=${info.type},direction=${info.direction},delta=${info.delta})` + : '' warn( - `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}". It should be called exactly one time in each navigation guard. This will fail in production.` + `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}"${showInfo}. It should be called exactly one time in each navigation guard. This will fail in production.` ) + } // @ts-expect-error: we put it in the original one because it's easier to check next._called = true if (called === 1) next.apply(null, arguments as any) @@ -235,7 +234,8 @@ export function extractComponentsGuards( guardType: GuardType, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - runWithContext: (fn: () => T) => T = fn => fn() + runWithContext: (fn: () => T) => T = fn => fn(), + info?: NavigationInformation ) { const guards: Array<() => Promise> = [] @@ -298,7 +298,15 @@ export function extractComponentsGuards( const guard = options[guardType] guard && guards.push( - guardToPromiseFn(guard, to, from, record, name, runWithContext) + guardToPromiseFn( + guard, + to, + from, + record, + name, + runWithContext, + info + ) ) } else { // start requesting the chunk already @@ -334,7 +342,15 @@ export function extractComponentsGuards( return ( guard && - guardToPromiseFn(guard, to, from, record, name, runWithContext)() + guardToPromiseFn( + guard, + to, + from, + record, + name, + runWithContext, + info + )() ) }) ) diff --git a/packages/router/src/typed-routes/navigation-guards.ts b/packages/router/src/typed-routes/navigation-guards.ts index ab624e1f7..f6e6e53d5 100644 --- a/packages/router/src/typed-routes/navigation-guards.ts +++ b/packages/router/src/typed-routes/navigation-guards.ts @@ -7,6 +7,7 @@ import type { import type { TypesConfig } from '../config' import type { NavigationFailure } from '../errors' import { ComponentPublicInstance } from 'vue' +import { NavigationInformation } from '../history/common' /** * Return types for a Navigation Guard. Based on `TypesConfig` @@ -25,7 +26,8 @@ export interface NavigationGuardWithThis { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } @@ -41,7 +43,8 @@ export interface _NavigationGuardResolved { to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } @@ -53,7 +56,8 @@ export interface NavigationGuard { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 222bd8adf..3657d9987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@rollup/plugin-terser': specifier: ^0.4.4 version: 0.4.4(rollup@3.29.5) + '@types/dom-navigation': + specifier: ^1.0.6 + version: 1.0.6 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 From 0a73c1871d83db8d838cd12728514f9ea63c9327 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 23:22:49 +0200 Subject: [PATCH 08/71] chore: remove spanish comment --- packages/router/src/navigation-api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 8a7748221..a71542e02 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -181,7 +181,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { lastSuccessfulLocation = to } currentRoute.value = to as RouteLocationNormalizedLoaded - markAsReady() // Marcamos como listo en la primera navegación exitosa + markAsReady() afterGuards.list().forEach(guard => guard(to, from, failure)) } From 33709ab88776945e6a46436dcf18029687860d35 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 12 Sep 2025 23:24:00 +0200 Subject: [PATCH 09/71] chore: fix navigation guards promises args --- packages/router/src/navigationGuards.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 4d7935534..df0c3a394 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -298,15 +298,12 @@ export function extractComponentsGuards( const guard = options[guardType] guard && guards.push( - guardToPromiseFn( - guard, - to, - from, + guardToPromiseFn(guard, to, from, { record, name, runWithContext, - info - ) + info, + }) ) } else { // start requesting the chunk already @@ -342,15 +339,12 @@ export function extractComponentsGuards( return ( guard && - guardToPromiseFn( - guard, - to, - from, + guardToPromiseFn(guard, to, from, { record, name, runWithContext, - info - )() + info, + })() ) }) ) From 58055b33edcaa9ced04270245206c82c780a5949 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 00:07:41 +0200 Subject: [PATCH 10/71] chore: try handling history nav. in the listener --- packages/router/src/navigation-api/index.ts | 68 ++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index a71542e02..7c2b4389c 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -86,6 +86,29 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { let started: boolean | undefined const installedApps = new Set() + function checkCanceledNavigation( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): NavigationFailure | void { + if (pendingLocation !== to) { + return createRouterError( + ErrorTypes.NAVIGATION_CANCELLED, + { + from, + to, + } + ) + } + } + + function checkCanceledNavigationAndReject( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): Promise { + const error = checkCanceledNavigation(to, from) + return error ? Promise.reject(error) : Promise.resolve() + } + function runWithContext(fn: () => T): T { const app: App | undefined = installedApps.values().next().value // support Vue < 3.3 @@ -119,6 +142,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { undefined, navigationInfo ) + + const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( + null, + to, + from + ) + + guards.push(canceledNavigationCheck) + await runGuardQueue(guards) guards = [] @@ -560,7 +592,17 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) } - function handleNavigate(event: NavigateEvent) { + async function handler(event: NavigateEvent) { + const destination = new URL(event.destination.url) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized + const from = currentRoute.value + pendingLocation = to + await resolveNavigationGuards(to, from) + } + + async function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return if (event.navigationType === 'traverse') { @@ -574,10 +616,16 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { delta > 0 ? NavigationDirection.forward : NavigationDirection.back, delta, } - } else if ( - event.navigationType === 'push' || - event.navigationType === 'replace' - ) { + + try { + await handler(event) + } catch { + event.preventDefault() + } + return + } + + if (event.navigationType === 'push' || event.navigationType === 'replace') { navigationInfo = { type: event.navigationType === 'push' @@ -589,15 +637,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } event.intercept({ - async handler() { - const destination = new URL(event.destination.url) - const pathWithSearchAndHash = - destination.pathname + destination.search + destination.hash - const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized - const from = currentRoute.value - pendingLocation = to - await resolveNavigationGuards(to, from) - }, + handler: () => handler(event), }) } From 97e37969fec62e8f03954cde224eff7f58c7c77b Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 00:21:27 +0200 Subject: [PATCH 11/71] chore: revert prevent default (not working) chore: fix route paths logic --- packages/router/src/navigation-api/index.ts | 47 ++++++++++----------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 7c2b4389c..f6b81d444 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -592,16 +592,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) } - async function handler(event: NavigateEvent) { - const destination = new URL(event.destination.url) - const pathWithSearchAndHash = - destination.pathname + destination.search + destination.hash - const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized - const from = currentRoute.value - pendingLocation = to - await resolveNavigationGuards(to, from) - } - async function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return @@ -616,16 +606,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { delta > 0 ? NavigationDirection.forward : NavigationDirection.back, delta, } - - try { - await handler(event) - } catch { - event.preventDefault() - } - return - } - - if (event.navigationType === 'push' || event.navigationType === 'replace') { + } else if ( + event.navigationType === 'push' || + event.navigationType === 'replace' + ) { navigationInfo = { type: event.navigationType === 'push' @@ -637,7 +621,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } event.intercept({ - handler: () => handler(event), + async handler() { + const destination = new URL(event.destination.url) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized + const from = currentRoute.value + pendingLocation = to + await resolveNavigationGuards(to, from) + }, }) } @@ -648,9 +640,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { return } - const to = resolve( - window.navigation.currentEntry!.url! - ) as RouteLocationNormalized + const destination = new URL(window.navigation.currentEntry!.url!) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized const from = lastSuccessfulLocation const fromIndex = event.from.index @@ -800,7 +793,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - navigate(options.location).catch(err => { + const initialLocation = + window.location.pathname + + window.location.search + + window.location.hash + navigate(initialLocation).catch(err => { if (__DEV__) warn('Unexpected error when starting the router:', err) }) } From 2e8af8fe337237bcbc1fa3d291c0bb445f5d529f Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 00:41:03 +0200 Subject: [PATCH 12/71] chore: remove navigation success and error listeners --- packages/router/src/navigation-api/index.ts | 119 +++++++++----------- 1 file changed, 51 insertions(+), 68 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index f6b81d444..c292c15e8 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -79,7 +79,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) let pendingLocation: RouteLocation | undefined - let navigationInfo: NavigationInformation | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = START_LOCATION_NORMALIZED @@ -129,7 +128,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function resolveNavigationGuards( to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded + from: RouteLocationNormalizedLoaded, + navigationInfo?: NavigationInformation ): Promise { const [leavingRecords, updatingRecords, enteringRecords] = extractChangingRecords(to, from) @@ -595,31 +595,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return - if (event.navigationType === 'traverse') { - const fromIndex = window.navigation.currentEntry?.index ?? -1 - const toIndex = event.destination.index - const delta = fromIndex === -1 ? 0 : toIndex - fromIndex - - navigationInfo = { - type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. - direction: - delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta, - } - } else if ( - event.navigationType === 'push' || - event.navigationType === 'replace' - ) { - navigationInfo = { - type: - event.navigationType === 'push' - ? NavigationType.push - : NavigationType.pop, - direction: NavigationDirection.unknown, // No specific direction for push/replace. - delta: event.navigationType === 'push' ? 1 : 0, - } - } - event.intercept({ async handler() { const destination = new URL(event.destination.url) @@ -628,7 +603,53 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized const from = currentRoute.value pendingLocation = to - await resolveNavigationGuards(to, from) + + let navigationInfo: NavigationInformation | undefined + if (event.navigationType === 'traverse') { + const fromIndex = window.navigation.currentEntry?.index ?? -1 + const toIndex = event.destination.index + const delta = fromIndex === -1 ? 0 : toIndex - fromIndex + + navigationInfo = { + type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. + direction: + delta > 0 + ? NavigationDirection.forward + : NavigationDirection.back, + delta, + } + } else if ( + event.navigationType === 'push' || + event.navigationType === 'replace' + ) { + navigationInfo = { + type: + event.navigationType === 'push' + ? NavigationType.push + : NavigationType.pop, + direction: NavigationDirection.unknown, // No specific direction for push/replace. + delta: event.navigationType === 'push' ? 1 : 0, + } + } + + try { + await resolveNavigationGuards(to, from, navigationInfo) + finalizeNavigation(to, from) + } catch (error) { + const failure = error as NavigationFailure + finalizeNavigation(to, from, failure) + + if ( + isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + navigate((failure as NavigationRedirectError).to, { replace: true }) + } else if ( + !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) + ) { + triggerError(failure, to, from) + } + throw failure + } }, }) } @@ -649,7 +670,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const fromIndex = event.from.index const toIndex = window.navigation.currentEntry!.index const delta = toIndex - fromIndex - navigationInfo = { + const navigationInfo: NavigationInformation = { type: NavigationType.pop, direction: delta > 0 ? NavigationDirection.forward : NavigationDirection.back, @@ -660,7 +681,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { try { // then browser has been done the navigation, we just run the guards - await resolveNavigationGuards(to, from) + await resolveNavigationGuards(to, from, navigationInfo) finalizeNavigation(to, from) } catch (error) { const failure = error as NavigationFailure @@ -677,44 +698,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } } - function handleNavigateSuccess(event: Event) { - navigationInfo = undefined - if (pendingLocation) { - finalizeNavigation( - resolve(pendingLocation) as RouteLocationNormalized, - currentRoute.value - ) - pendingLocation = undefined - } - } - - function handleNavigateError(event: ErrorEvent) { - navigationInfo = undefined - - const failure = event.error as NavigationFailure - - if (pendingLocation) { - const to = pendingLocation as RouteLocationNormalized - const from = currentRoute.value - pendingLocation = undefined - - finalizeNavigation(to, from, failure) - - if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { - navigate((failure as NavigationRedirectError).to, { replace: true }) - } else { - triggerError(failure, to, from) - } - } - } - window.navigation.addEventListener('navigate', handleNavigate) window.navigation.addEventListener( 'currententrychange', handleCurrentEntryChange ) - window.navigation.addEventListener('navigatesuccess', handleNavigateSuccess) - window.navigation.addEventListener('navigateerror', handleNavigateError) function destroy() { window.navigation.removeEventListener('navigate', handleNavigate) @@ -722,11 +710,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'currententrychange', handleCurrentEntryChange ) - window.navigation.removeEventListener( - 'navigatesuccess', - handleNavigateSuccess - ) - window.navigation.removeEventListener('navigateerror', handleNavigateError) } const history: RouterHistory = { From 2973232d6358150edcce25e9e7bc74a7a75e81f1 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 01:04:29 +0200 Subject: [PATCH 13/71] chore: add isRevertingNavigation guard --- packages/router/src/navigation-api/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index c292c15e8..21eb04829 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -78,6 +78,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { START_LOCATION_NORMALIZED ) + let isRevertingNavigation = false let pendingLocation: RouteLocation | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = START_LOCATION_NORMALIZED @@ -657,6 +658,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function handleCurrentEntryChange( event: NavigationCurrentEntryChangeEvent ) { + if (isRevertingNavigation) { + isRevertingNavigation = false + return + } + if (event.navigationType !== 'traverse') { return } @@ -686,6 +692,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } catch (error) { const failure = error as NavigationFailure + isRevertingNavigation = true go(fromIndex - toIndex) finalizeNavigation(from, to, failure) From 2d21d5084f130e28240d2c6f32006fd1d7a7cb97 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 01:28:02 +0200 Subject: [PATCH 14/71] chore: don't call finalizeNavigation on error --- packages/router/src/navigation-api/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 21eb04829..18bf3b4d6 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -638,7 +638,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { finalizeNavigation(to, from) } catch (error) { const failure = error as NavigationFailure - finalizeNavigation(to, from, failure) if ( isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) @@ -647,7 +646,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } else if ( !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) ) { - triggerError(failure, to, from) + await triggerError(failure, to, from) } throw failure } @@ -695,8 +694,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { isRevertingNavigation = true go(fromIndex - toIndex) - finalizeNavigation(from, to, failure) - if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { navigate((failure as NavigationRedirectError).to, { replace: true }) } else { From 9772a2e26b4b86cbc6ee215e7b3ef2c06df7fbb2 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 01:44:52 +0200 Subject: [PATCH 15/71] chore: add initial bootstrap --- packages/router/src/navigation-api/index.ts | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 18bf3b4d6..81ddeb597 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -716,6 +716,33 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) } + async function start(location: RouteLocationRaw) { + const to = resolve(location) as RouteLocationNormalized + const from = START_LOCATION_NORMALIZED + + pendingLocation = to + + try { + await resolveNavigationGuards(to, from) + finalizeNavigation(to, from) + } catch (error) { + const failure = error as NavigationFailure + // at start, a guard failure cannot be reverted, we just notify. + finalizeNavigation(to, from, failure) + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { + // if there is a redirect, we navigate to it replacing the current entry. + await navigate((failure as NavigationRedirectError).to, { + replace: true, + }) + } else { + // for other errors we just notify, the user is left in the blank page. + triggerError(failure, to, from) + } + } finally { + pendingLocation = null + } + } + const history: RouterHistory = { base: options.base || '/', location: options.location, @@ -784,7 +811,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { window.location.pathname + window.location.search + window.location.hash - navigate(initialLocation).catch(err => { + start(initialLocation).catch(err => { if (__DEV__) warn('Unexpected error when starting the router:', err) }) } From 1618c6c589672a1d765182581f92eef1a68a19e1 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 01:46:31 +0200 Subject: [PATCH 16/71] chore: fix build error --- packages/router/src/navigation-api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 81ddeb597..ae71f4f4a 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -739,7 +739,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { triggerError(failure, to, from) } } finally { - pendingLocation = null + pendingLocation = undefined } } From fd0ff062e23a322a430c667b8e319522d52fb5df Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 01:55:16 +0200 Subject: [PATCH 17/71] chore: add back finalizeNavigation with failure on error --- packages/router/src/navigation-api/index.ts | 35 ++++----------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index ae71f4f4a..f76f40450 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -639,6 +639,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } catch (error) { const failure = error as NavigationFailure + finalizeNavigation(to, from, failure) + if ( isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) ) { @@ -646,7 +648,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } else if ( !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) ) { - await triggerError(failure, to, from) + triggerError(failure, to, from) } throw failure } @@ -694,6 +696,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { isRevertingNavigation = true go(fromIndex - toIndex) + finalizeNavigation(to, from, failure) + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { navigate((failure as NavigationRedirectError).to, { replace: true }) } else { @@ -716,33 +720,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) } - async function start(location: RouteLocationRaw) { - const to = resolve(location) as RouteLocationNormalized - const from = START_LOCATION_NORMALIZED - - pendingLocation = to - - try { - await resolveNavigationGuards(to, from) - finalizeNavigation(to, from) - } catch (error) { - const failure = error as NavigationFailure - // at start, a guard failure cannot be reverted, we just notify. - finalizeNavigation(to, from, failure) - if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { - // if there is a redirect, we navigate to it replacing the current entry. - await navigate((failure as NavigationRedirectError).to, { - replace: true, - }) - } else { - // for other errors we just notify, the user is left in the blank page. - triggerError(failure, to, from) - } - } finally { - pendingLocation = undefined - } - } - const history: RouterHistory = { base: options.base || '/', location: options.location, @@ -811,7 +788,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { window.location.pathname + window.location.search + window.location.hash - start(initialLocation).catch(err => { + navigate(initialLocation).catch(err => { if (__DEV__) warn('Unexpected error when starting the router:', err) }) } From 257873865e7871d0b89dd783e66b574d1c8d3e25 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 02:08:26 +0200 Subject: [PATCH 18/71] chore: change error logic --- packages/router/src/navigation-api/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index f76f40450..91b480c92 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -639,7 +639,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } catch (error) { const failure = error as NavigationFailure - finalizeNavigation(to, from, failure) + afterGuards.list().forEach(guard => guard(to, from, failure)) if ( isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) @@ -696,11 +696,13 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { isRevertingNavigation = true go(fromIndex - toIndex) - finalizeNavigation(to, from, failure) + afterGuards.list().forEach(guard => guard(to, from, failure)) if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { navigate((failure as NavigationRedirectError).to, { replace: true }) - } else { + } else if ( + !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) + ) { triggerError(failure, to, from) } } From 88dd20b12cba9d6606a770e9f7fcd3ebda034ef1 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 02:19:03 +0200 Subject: [PATCH 19/71] chore: update navigation error and initial navigation logic --- packages/router/src/navigation-api/index.ts | 33 ++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 91b480c92..b68986915 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -651,6 +651,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { triggerError(failure, to, from) } throw failure + } finally { + // update always, we'll have some race condition it the user clicks 2 links + pendingLocation = undefined } }, }) @@ -696,7 +699,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { isRevertingNavigation = true go(fromIndex - toIndex) - afterGuards.list().forEach(guard => guard(to, from, failure)) + // we end up at from to keep consistency + finalizeNavigation(from, to, failure) if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { navigate((failure as NavigationRedirectError).to, { replace: true }) @@ -705,6 +709,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { triggerError(failure, to, from) } + } finally { + // update always, we'll have some race condition it the user clicks 2 links + pendingLocation = undefined } } @@ -786,13 +793,25 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - const initialLocation = + const initialLocation = resolve( window.location.pathname + - window.location.search + - window.location.hash - navigate(initialLocation).catch(err => { - if (__DEV__) warn('Unexpected error when starting the router:', err) - }) + window.location.search + + window.location.hash + ) as RouteLocationNormalized + resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) + .then(() => { + finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) + }) + .catch(err => { + if ( + isNavigationFailure(err, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + navigate(err.to, { replace: true }) + } else { + if (__DEV__) + warn('Unexpected error when starting the router:', err) + } + }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From 7895f7774e138961ed66d0c1ab1929d444aa9601 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 14:02:41 +0200 Subject: [PATCH 20/71] chore: revert plugin boostrap --- packages/router/src/navigation-api/index.ts | 30 ++++++++------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index b68986915..81c079506 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -697,7 +697,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const failure = error as NavigationFailure isRevertingNavigation = true - go(fromIndex - toIndex) + go(event.from.index - window.navigation.currentEntry!.index) // we end up at from to keep consistency finalizeNavigation(from, to, failure) @@ -793,25 +793,17 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - const initialLocation = resolve( + // De esta forma, el navegador siempre sabe qué está pasando. + const initialLocation = window.location.pathname + - window.location.search + - window.location.hash - ) as RouteLocationNormalized - resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) - .then(() => { - finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) - }) - .catch(err => { - if ( - isNavigationFailure(err, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ) { - navigate(err.to, { replace: true }) - } else { - if (__DEV__) - warn('Unexpected error when starting the router:', err) - } - }) + window.location.search + + window.location.hash + + navigate(initialLocation).catch(err => { + // El `catch` aquí es solo para errores catastróficos. + // Los fallos de guards ya los gestiona el handler. + if (__DEV__) warn('Unexpected error when starting the router:', err) + }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From 2b6df78653eee53663deee2ea4d1cfe0eba6c317 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 14:21:01 +0200 Subject: [PATCH 21/71] chore: change bootstrap logic --- packages/router/src/navigation-api/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 81c079506..a3b5569b6 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -602,7 +602,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const pathWithSearchAndHash = destination.pathname + destination.search + destination.hash const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized - const from = currentRoute.value + const from = + currentRoute.value === START_LOCATION_NORMALIZED + ? lastSuccessfulLocation + : currentRoute.value + pendingLocation = to let navigationInfo: NavigationInformation | undefined @@ -793,17 +797,13 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - // De esta forma, el navegador siempre sabe qué está pasando. const initialLocation = window.location.pathname + window.location.search + window.location.hash - - navigate(initialLocation).catch(err => { - // El `catch` aquí es solo para errores catastróficos. - // Los fallos de guards ya los gestiona el handler. - if (__DEV__) warn('Unexpected error when starting the router:', err) - }) + lastSuccessfulLocation = resolve( + initialLocation + ) as RouteLocationNormalized } const reactiveRoute = {} as RouteLocationNormalizedLoaded From 34478a7025dfa3b3215edd8efdc40171289d4f52 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 14:35:11 +0200 Subject: [PATCH 22/71] chore: add back and forward info to the navigation info --- packages/router/src/history/common.ts | 8 ++++++++ packages/router/src/navigation-api/index.ts | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/packages/router/src/history/common.ts b/packages/router/src/history/common.ts index 0abf48150..ba7fc5e5f 100644 --- a/packages/router/src/history/common.ts +++ b/packages/router/src/history/common.ts @@ -48,6 +48,14 @@ export interface NavigationInformation { type: NavigationType direction: NavigationDirection delta: number + /** + * True if the navigation was triggered by the browser back button. + */ + isBackBrowserButton?: boolean + /** + * True if the navigation was triggered by the browser forward button. + */ + isForwardBrowserButton?: boolean } export interface NavigationCallback { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index a3b5569b6..847dc0f7a 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -622,6 +622,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ? NavigationDirection.forward : NavigationDirection.back, delta, + isBackBrowserButton: delta < 0, + isForwardBrowserButton: delta > 0, } } else if ( event.navigationType === 'push' || @@ -689,6 +691,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { direction: delta > 0 ? NavigationDirection.forward : NavigationDirection.back, delta, + isBackBrowserButton: delta < 0, + isForwardBrowserButton: delta > 0, } pendingLocation = to From be8a320fcf77877c6fbb12b4db1d4dcbc94d2bd7 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 14:46:24 +0200 Subject: [PATCH 23/71] chore: fire initial navigation to load components --- packages/router/src/navigation-api/index.ts | 35 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 847dc0f7a..ddc3e9cb3 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -78,6 +78,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { START_LOCATION_NORMALIZED ) + let initialNavigation = true let isRevertingNavigation = false let pendingLocation: RouteLocation | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = @@ -596,6 +597,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return + if (initialNavigation) { + initialNavigation = false + return + } + event.intercept({ async handler() { const destination = new URL(event.destination.url) @@ -801,13 +807,32 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - const initialLocation = + const initialLocation = resolve( window.location.pathname + - window.location.search + - window.location.hash - lastSuccessfulLocation = resolve( - initialLocation + window.location.search + + window.location.hash ) as RouteLocationNormalized + pendingLocation = initialLocation + resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) + .then(() => { + finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) + }) + .catch(err => { + const failure = err as NavigationFailure + if ( + isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + return navigate((failure as NavigationRedirectError).to, { + replace: true, + }) + } else { + return triggerError( + failure, + initialLocation, + START_LOCATION_NORMALIZED + ) + } + }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From c5d77aec1a477ba3c7811ded2ecfc4b408a81dd0 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 15:38:16 +0200 Subject: [PATCH 24/71] chore: update resolveNavigationGuards logic --- packages/router/src/navigation-api/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index ddc3e9cb3..3363ae5e7 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -136,6 +136,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const [leavingRecords, updatingRecords, enteringRecords] = extractChangingRecords(to, from) + // run the queue of per route beforeRouteLeave guards let guards = extractComponentsGuards( leavingRecords.reverse(), 'beforeRouteLeave', @@ -155,20 +156,25 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { await runGuardQueue(guards) + // check global guards beforeEach guards = [] for (const guard of beforeGuards.list()) { guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } + guards.push(canceledNavigationCheck) await runGuardQueue(guards) + // check in components beforeRouteUpdate guards = extractComponentsGuards( updatingRecords, 'beforeRouteUpdate', to, from ) + guards.push(canceledNavigationCheck) await runGuardQueue(guards) + // check the route beforeEnter guards = [] for (const record of enteringRecords) { if (record.beforeEnter) { @@ -186,23 +192,31 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } } } + guards.push(canceledNavigationCheck) await runGuardQueue(guards) + // NOTE: at this point to.matched is normalized and does not contain any () => Promise + // clear existing enterCallbacks, these are added by extractComponentsGuards + to.matched.forEach(record => (record.enterCallbacks = {})) + // Resolve async components and run beforeRouteEnter guards = extractComponentsGuards( enteringRecords, 'beforeRouteEnter', to, from, - undefined, + runWithContext, navigationInfo ) + guards.push(canceledNavigationCheck) await runGuardQueue(guards) + // check global guards beforeResolve guards = [] for (const guard of beforeResolveGuards.list()) { guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } + guards.push(canceledNavigationCheck) await runGuardQueue(guards) } From 50a675a186ef3e6b17f7c37a8062c097bbe4d57a Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 13 Sep 2025 16:22:31 +0200 Subject: [PATCH 25/71] chore: remove functional functions --- packages/router/src/navigation-api/index.ts | 26 ++++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 3363ae5e7..d5ef87094 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -90,7 +90,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { function checkCanceledNavigation( to: RouteLocationNormalized, from: RouteLocationNormalized - ): NavigationFailure | void { + ): NavigationFailure | undefined { if (pendingLocation !== to) { return createRouterError( ErrorTypes.NAVIGATION_CANCELLED, @@ -100,14 +100,16 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } ) } + + return undefined } function checkCanceledNavigationAndReject( to: RouteLocationNormalized, from: RouteLocationNormalized - ): Promise { + ) { const error = checkCanceledNavigation(to, from) - return error ? Promise.reject(error) : Promise.resolve() + if (error) throw error } function runWithContext(fn: () => T): T { @@ -118,11 +120,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { : fn() } - function runGuardQueue(guards: Lazy[]): Promise { - return guards.reduce( - (promise, guard) => promise.then(() => runWithContext(guard)), - Promise.resolve() - ) + async function runGuardQueue(guards: Lazy[]): Promise { + for (const guard of guards) { + await runWithContext(guard) + } } let ready: boolean = false @@ -146,14 +147,11 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { navigationInfo ) - const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( - null, - to, - from - ) + const canceledNavigationCheck = async () => { + checkCanceledNavigationAndReject(to, from) + } guards.push(canceledNavigationCheck) - await runGuardQueue(guards) // check global guards beforeEach From 92d981297644864485ea9a1c0a2f857ec037488b Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 13:43:31 +0200 Subject: [PATCH 26/71] chore: revert guard changes --- packages/router/src/history/common.ts | 8 --- packages/router/src/navigation-api/index.ts | 32 ++++------- packages/router/src/navigationGuards.ts | 56 ++++++++----------- .../src/typed-routes/navigation-guards.ts | 10 +--- 4 files changed, 38 insertions(+), 68 deletions(-) diff --git a/packages/router/src/history/common.ts b/packages/router/src/history/common.ts index ba7fc5e5f..0abf48150 100644 --- a/packages/router/src/history/common.ts +++ b/packages/router/src/history/common.ts @@ -48,14 +48,6 @@ export interface NavigationInformation { type: NavigationType direction: NavigationDirection delta: number - /** - * True if the navigation was triggered by the browser back button. - */ - isBackBrowserButton?: boolean - /** - * True if the navigation was triggered by the browser forward button. - */ - isForwardBrowserButton?: boolean } export interface NavigationCallback { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index d5ef87094..6ac5c14a0 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1,4 +1,5 @@ -import { App, shallowReactive, shallowRef, unref } from 'vue' +import type { App } from 'vue' +import { shallowReactive, shallowRef, unref } from 'vue' import { parseURL, stringifyURL, @@ -142,9 +143,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { leavingRecords.reverse(), 'beforeRouteLeave', to, - from, - undefined, - navigationInfo + from ) const canceledNavigationCheck = async () => { @@ -157,7 +156,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { // check global guards beforeEach guards = [] for (const guard of beforeGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) + guards.push(guardToPromiseFn(guard, to, from)) } guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -178,15 +177,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { if (record.beforeEnter) { if (isArray(record.beforeEnter)) { for (const beforeEnter of record.beforeEnter) - guards.push( - guardToPromiseFn(beforeEnter, to, from, { info: navigationInfo }) - ) + guards.push(guardToPromiseFn(beforeEnter, to, from)) } else { - guards.push( - guardToPromiseFn(record.beforeEnter, to, from, { - info: navigationInfo, - }) - ) + guards.push(guardToPromiseFn(record.beforeEnter, to, from)) } } } @@ -203,8 +196,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'beforeRouteEnter', to, from, - runWithContext, - navigationInfo + runWithContext ) guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -212,7 +204,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { // check global guards beforeResolve guards = [] for (const guard of beforeResolveGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) + guards.push(guardToPromiseFn(guard, to, from)) } guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -639,9 +631,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta, + delta /*, isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0, + isForwardBrowserButton: delta > 0*/, } } else if ( event.navigationType === 'push' || @@ -708,9 +700,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { type: NavigationType.pop, direction: delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta, + delta /*, isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0, + isForwardBrowserButton: delta > 0*/, } pendingLocation = to diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index df0c3a394..db53c3dc1 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -22,7 +22,6 @@ import { matchedRouteKey } from './injectionSymbols' import { RouteRecordNormalized } from './matcher/types' import { isESModule, isRouteComponent } from './utils' import { warn } from './warning' -import { NavigationInformation } from './history/common' function registerGuard( record: RouteRecordNormalized, @@ -107,20 +106,27 @@ export function onBeforeRouteUpdate(updateGuard: NavigationGuard) { registerGuard(activeRecord, 'updateGuards', updateGuard) } -interface GuardToPromiseFnOptions { - record?: RouteRecordNormalized - name?: string - runWithContext?: (fn: () => T) => T - info?: NavigationInformation -} - +export function guardToPromiseFn( + guard: NavigationGuard, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded +): () => Promise export function guardToPromiseFn( guard: NavigationGuard, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - options: GuardToPromiseFnOptions = {} + record: RouteRecordNormalized, + name: string, + runWithContext: (fn: () => T) => T +): () => Promise +export function guardToPromiseFn( + guard: NavigationGuard, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded, + record?: RouteRecordNormalized, + name?: string, + runWithContext: (fn: () => T) => T = fn => fn() ): () => Promise { - const { record, name, runWithContext = fn => fn(), info } = options // keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place const enterCallbackArray = record && @@ -173,7 +179,7 @@ export function guardToPromiseFn( record && record.instances[name!], to, from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next + __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next ) ) let guardCall = Promise.resolve(guardReturn) @@ -208,19 +214,14 @@ export function guardToPromiseFn( function canOnlyBeCalledOnce( next: NavigationGuardNext, to: RouteLocationNormalized, - from: RouteLocationNormalized, - info?: NavigationInformation + from: RouteLocationNormalized ): NavigationGuardNext { let called = 0 return function () { - if (called++ === 1) { - const showInfo = info - ? ` (type=${info.type},direction=${info.direction},delta=${info.delta})` - : '' + if (called++ === 1) warn( - `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}"${showInfo}. It should be called exactly one time in each navigation guard. This will fail in production.` + `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}". It should be called exactly one time in each navigation guard. This will fail in production.` ) - } // @ts-expect-error: we put it in the original one because it's easier to check next._called = true if (called === 1) next.apply(null, arguments as any) @@ -234,8 +235,7 @@ export function extractComponentsGuards( guardType: GuardType, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - runWithContext: (fn: () => T) => T = fn => fn(), - info?: NavigationInformation + runWithContext: (fn: () => T) => T = fn => fn() ) { const guards: Array<() => Promise> = [] @@ -298,12 +298,7 @@ export function extractComponentsGuards( const guard = options[guardType] guard && guards.push( - guardToPromiseFn(guard, to, from, { - record, - name, - runWithContext, - info, - }) + guardToPromiseFn(guard, to, from, record, name, runWithContext) ) } else { // start requesting the chunk already @@ -339,12 +334,7 @@ export function extractComponentsGuards( return ( guard && - guardToPromiseFn(guard, to, from, { - record, - name, - runWithContext, - info, - })() + guardToPromiseFn(guard, to, from, record, name, runWithContext)() ) }) ) diff --git a/packages/router/src/typed-routes/navigation-guards.ts b/packages/router/src/typed-routes/navigation-guards.ts index f6e6e53d5..ab624e1f7 100644 --- a/packages/router/src/typed-routes/navigation-guards.ts +++ b/packages/router/src/typed-routes/navigation-guards.ts @@ -7,7 +7,6 @@ import type { import type { TypesConfig } from '../config' import type { NavigationFailure } from '../errors' import { ComponentPublicInstance } from 'vue' -import { NavigationInformation } from '../history/common' /** * Return types for a Navigation Guard. Based on `TypesConfig` @@ -26,8 +25,7 @@ export interface NavigationGuardWithThis { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext, - info?: NavigationInformation + next: NavigationGuardNext ): _Awaitable } @@ -43,8 +41,7 @@ export interface _NavigationGuardResolved { to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext, - info?: NavigationInformation + next: NavigationGuardNext ): _Awaitable } @@ -56,8 +53,7 @@ export interface NavigationGuard { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext, - info?: NavigationInformation + next: NavigationGuardNext ): _Awaitable } From 44d4ae97965af89cab541a175861eca7362b28ed Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 14:15:35 +0200 Subject: [PATCH 27/71] chore: change boostrap logic --- packages/router/src/navigation-api/index.ts | 39 +++++---------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 6ac5c14a0..db995b674 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -612,10 +612,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { const pathWithSearchAndHash = destination.pathname + destination.search + destination.hash const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized - const from = - currentRoute.value === START_LOCATION_NORMALIZED - ? lastSuccessfulLocation - : currentRoute.value + const from = currentRoute.value pendingLocation = to @@ -649,6 +646,14 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } } + if ( + from !== START_LOCATION_NORMALIZED && + !(to as RouteLocationOptions).force && + isSameRouteLocation(stringifyQuery, from, to) + ) { + return + } + try { await resolveNavigationGuards(to, from, navigationInfo) finalizeNavigation(to, from) @@ -811,32 +816,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - const initialLocation = resolve( - window.location.pathname + - window.location.search + - window.location.hash - ) as RouteLocationNormalized - pendingLocation = initialLocation - resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) - .then(() => { - finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) - }) - .catch(err => { - const failure = err as NavigationFailure - if ( - isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ) { - return navigate((failure as NavigationRedirectError).to, { - replace: true, - }) - } else { - return triggerError( - failure, - initialLocation, - START_LOCATION_NORMALIZED - ) - } - }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From ebaf406127b3bc22b0476933b786c5e9e7638b83 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 14:23:42 +0200 Subject: [PATCH 28/71] chore: . --- packages/router/src/navigation-api/index.ts | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index db995b674..114202e7c 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -816,6 +816,32 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true + const initialLocation = resolve( + window.location.pathname + + window.location.search + + window.location.hash + ) as RouteLocationNormalized + pendingLocation = initialLocation + resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) + .then(() => { + finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) + }) + .catch(err => { + const failure = err as NavigationFailure + if ( + isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + return navigate((failure as NavigationRedirectError).to, { + replace: true, + }) + } else { + return triggerError( + failure, + initialLocation, + START_LOCATION_NORMALIZED + ) + } + }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From e06f13ca23e91573ec87a256cbc79514247effb8 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 14:30:45 +0200 Subject: [PATCH 29/71] chore: abort logic on navigation duplicated --- packages/router/src/navigation-api/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 114202e7c..7aa5da9a8 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -259,6 +259,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } function go(delta: number) { + if (delta === 0 && isRevertingNavigation) { + return + } + // Case 1: go(0) should trigger a reload. if (delta === 0) { window.navigation.reload() @@ -718,6 +722,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { finalizeNavigation(to, from) } catch (error) { const failure = error as NavigationFailure + if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_DUPLICATED)) { + return + } isRevertingNavigation = true go(event.from.index - window.navigation.currentEntry!.index) From dd56ef05ca9db62a19b4ad5c740b4746410c4944 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 15:08:03 +0200 Subject: [PATCH 30/71] chore: change logic --- packages/router/src/navigation-api/index.ts | 61 ++++++--------------- 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 7aa5da9a8..3e3ede9a2 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -79,7 +79,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { START_LOCATION_NORMALIZED ) - let initialNavigation = true let isRevertingNavigation = false let pendingLocation: RouteLocation | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = @@ -215,6 +214,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { from: RouteLocationNormalizedLoaded, failure?: NavigationFailure ) { + pendingLocation = undefined if (!failure) { lastSuccessfulLocation = to } @@ -259,10 +259,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } function go(delta: number) { - if (delta === 0 && isRevertingNavigation) { - return - } - // Case 1: go(0) should trigger a reload. if (delta === 0) { window.navigation.reload() @@ -605,20 +601,19 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { async function handleNavigate(event: NavigateEvent) { if (!event.canIntercept) return - if (initialNavigation) { - initialNavigation = false - return - } - event.intercept({ async handler() { - const destination = new URL(event.destination.url) - const pathWithSearchAndHash = - destination.pathname + destination.search + destination.hash - const to = resolve(pathWithSearchAndHash) as RouteLocationNormalized - const from = currentRoute.value + if (!pendingLocation) { + const destination = new URL(event.destination.url) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + pendingLocation = resolve( + pathWithSearchAndHash + ) as RouteLocationNormalized + } - pendingLocation = to + const to = pendingLocation + const from = currentRoute.value let navigationInfo: NavigationInformation | undefined if (event.navigationType === 'traverse') { @@ -722,9 +717,6 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { finalizeNavigation(to, from) } catch (error) { const failure = error as NavigationFailure - if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_DUPLICATED)) { - return - } isRevertingNavigation = true go(event.from.index - window.navigation.currentEntry!.index) @@ -823,32 +815,13 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) { // see above started = true - const initialLocation = resolve( + const initialLocation = window.location.pathname + - window.location.search + - window.location.hash - ) as RouteLocationNormalized - pendingLocation = initialLocation - resolveNavigationGuards(initialLocation, START_LOCATION_NORMALIZED) - .then(() => { - finalizeNavigation(initialLocation, START_LOCATION_NORMALIZED) - }) - .catch(err => { - const failure = err as NavigationFailure - if ( - isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) - ) { - return navigate((failure as NavigationRedirectError).to, { - replace: true, - }) - } else { - return triggerError( - failure, - initialLocation, - START_LOCATION_NORMALIZED - ) - } - }) + window.location.search + + window.location.hash + navigate(initialLocation).catch(err => { + if (__DEV__) warn('Unexpected error when starting the router:', err) + }) } const reactiveRoute = {} as RouteLocationNormalizedLoaded From aa89041a546960551163ffee9f2ad25c85269250 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 15:10:37 +0200 Subject: [PATCH 31/71] chore: fix to at intercept --- packages/router/src/navigation-api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 3e3ede9a2..3529c4644 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -612,7 +612,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { ) as RouteLocationNormalized } - const to = pendingLocation + const to = pendingLocation as RouteLocationNormalized const from = currentRoute.value let navigationInfo: NavigationInformation | undefined From eac6b635f3fa47f0da5b2303d4a2f5568c7bfff2 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 15:29:15 +0200 Subject: [PATCH 32/71] chore: add missing leaving routes guards --- packages/router/src/navigation-api/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 3529c4644..7ee794a07 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -145,6 +145,13 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { from ) + // leavingRecords is already reversed + for (const record of leavingRecords) { + record.leaveGuards.forEach(guard => { + guards.push(guardToPromiseFn(guard, to, from)) + }) + } + const canceledNavigationCheck = async () => { checkCanceledNavigationAndReject(to, from) } From bd15fd379d72bda76e8ca733243d4d5243180e1a Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 15:58:57 +0200 Subject: [PATCH 33/71] chore: add back navigation info to guards --- packages/router/src/history/common.ts | 8 +++ packages/router/src/navigation-api/index.ts | 31 +++++++---- packages/router/src/navigationGuards.ts | 55 +++++++++++-------- packages/router/src/router.ts | 1 + .../src/typed-routes/navigation-guards.ts | 10 +++- 5 files changed, 69 insertions(+), 36 deletions(-) diff --git a/packages/router/src/history/common.ts b/packages/router/src/history/common.ts index 0abf48150..ba7fc5e5f 100644 --- a/packages/router/src/history/common.ts +++ b/packages/router/src/history/common.ts @@ -48,6 +48,14 @@ export interface NavigationInformation { type: NavigationType direction: NavigationDirection delta: number + /** + * True if the navigation was triggered by the browser back button. + */ + isBackBrowserButton?: boolean + /** + * True if the navigation was triggered by the browser forward button. + */ + isForwardBrowserButton?: boolean } export interface NavigationCallback { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 7ee794a07..81a8a8818 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -142,13 +142,14 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { leavingRecords.reverse(), 'beforeRouteLeave', to, - from + from, + navigationInfo ) // leavingRecords is already reversed for (const record of leavingRecords) { record.leaveGuards.forEach(guard => { - guards.push(guardToPromiseFn(guard, to, from)) + guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) }) } @@ -162,7 +163,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { // check global guards beforeEach guards = [] for (const guard of beforeGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) + guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -172,7 +173,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { updatingRecords, 'beforeRouteUpdate', to, - from + from, + navigationInfo ) guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -183,9 +185,15 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { if (record.beforeEnter) { if (isArray(record.beforeEnter)) { for (const beforeEnter of record.beforeEnter) - guards.push(guardToPromiseFn(beforeEnter, to, from)) + guards.push( + guardToPromiseFn(beforeEnter, to, from, { info: navigationInfo }) + ) } else { - guards.push(guardToPromiseFn(record.beforeEnter, to, from)) + guards.push( + guardToPromiseFn(record.beforeEnter, to, from, { + info: navigationInfo, + }) + ) } } } @@ -202,6 +210,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'beforeRouteEnter', to, from, + navigationInfo, runWithContext ) guards.push(canceledNavigationCheck) @@ -210,7 +219,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { // check global guards beforeResolve guards = [] for (const guard of beforeResolveGuards.list()) { - guards.push(guardToPromiseFn(guard, to, from)) + guards.push(guardToPromiseFn(guard, to, from, { info: navigationInfo })) } guards.push(canceledNavigationCheck) await runGuardQueue(guards) @@ -634,9 +643,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta /*, + delta, isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0*/, + isForwardBrowserButton: delta > 0, } } else if ( event.navigationType === 'push' || @@ -711,9 +720,9 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { type: NavigationType.pop, direction: delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta /*, + delta, isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0*/, + isForwardBrowserButton: delta > 0, } pendingLocation = to diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index db53c3dc1..87897ad67 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -22,6 +22,7 @@ import { matchedRouteKey } from './injectionSymbols' import { RouteRecordNormalized } from './matcher/types' import { isESModule, isRouteComponent } from './utils' import { warn } from './warning' +import { NavigationInformation } from './history/common' function registerGuard( record: RouteRecordNormalized, @@ -106,27 +107,21 @@ export function onBeforeRouteUpdate(updateGuard: NavigationGuard) { registerGuard(activeRecord, 'updateGuards', updateGuard) } -export function guardToPromiseFn( - guard: NavigationGuard, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded -): () => Promise -export function guardToPromiseFn( - guard: NavigationGuard, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded, - record: RouteRecordNormalized, - name: string, - runWithContext: (fn: () => T) => T -): () => Promise +interface GuardToPromiseFnOptions { + record?: RouteRecordNormalized + name?: string + runWithContext?: (fn: () => T) => T + info?: NavigationInformation +} + export function guardToPromiseFn( guard: NavigationGuard, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - record?: RouteRecordNormalized, - name?: string, - runWithContext: (fn: () => T) => T = fn => fn() + options: GuardToPromiseFnOptions = {} ): () => Promise { + const { record, name, runWithContext = fn => fn(), info } = options + // keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place const enterCallbackArray = record && @@ -179,7 +174,7 @@ export function guardToPromiseFn( record && record.instances[name!], to, from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next + __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next ) ) let guardCall = Promise.resolve(guardReturn) @@ -214,14 +209,19 @@ export function guardToPromiseFn( function canOnlyBeCalledOnce( next: NavigationGuardNext, to: RouteLocationNormalized, - from: RouteLocationNormalized + from: RouteLocationNormalized, + info?: NavigationInformation ): NavigationGuardNext { let called = 0 return function () { - if (called++ === 1) + if (called++ === 1) { + const showInfo = info + ? ` (type=${info.type},direction=${info.direction},delta=${info.delta},back=${info.isBackBrowserButton === true},forward=${info.isForwardBrowserButton === true})` + : '' warn( - `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}". It should be called exactly one time in each navigation guard. This will fail in production.` + `The "next" callback was called more than once in one navigation guard when going from "${from.fullPath}" to "${to.fullPath}${showInfo}". It should be called exactly one time in each navigation guard. This will fail in production.` ) + } // @ts-expect-error: we put it in the original one because it's easier to check next._called = true if (called === 1) next.apply(null, arguments as any) @@ -235,6 +235,7 @@ export function extractComponentsGuards( guardType: GuardType, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, + info?: NavigationInformation, runWithContext: (fn: () => T) => T = fn => fn() ) { const guards: Array<() => Promise> = [] @@ -298,7 +299,12 @@ export function extractComponentsGuards( const guard = options[guardType] guard && guards.push( - guardToPromiseFn(guard, to, from, record, name, runWithContext) + guardToPromiseFn(guard, to, from, { + record, + name, + runWithContext, + info, + }) ) } else { // start requesting the chunk already @@ -334,7 +340,12 @@ export function extractComponentsGuards( return ( guard && - guardToPromiseFn(guard, to, from, record, name, runWithContext)() + guardToPromiseFn(guard, to, from, { + record, + name, + runWithContext, + info, + })() ) }) ) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index bc448bbd5..e26a96295 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -923,6 +923,7 @@ export function createRouter(options: RouterOptions): Router { 'beforeRouteEnter', to, from, + undefined, runWithContext ) guards.push(canceledNavigationCheck) diff --git a/packages/router/src/typed-routes/navigation-guards.ts b/packages/router/src/typed-routes/navigation-guards.ts index ab624e1f7..35e6bfba7 100644 --- a/packages/router/src/typed-routes/navigation-guards.ts +++ b/packages/router/src/typed-routes/navigation-guards.ts @@ -6,6 +6,7 @@ import type { } from './route-location' import type { TypesConfig } from '../config' import type { NavigationFailure } from '../errors' +import type { NavigationInformation } from '../history/common' import { ComponentPublicInstance } from 'vue' /** @@ -25,7 +26,8 @@ export interface NavigationGuardWithThis { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } @@ -41,7 +43,8 @@ export interface _NavigationGuardResolved { to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } @@ -53,7 +56,8 @@ export interface NavigationGuard { to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, // intentionally not typed to make people use the return - next: NavigationGuardNext + next: NavigationGuardNext, + info?: NavigationInformation ): _Awaitable } From cec1347748ec73a0201eea2a3e4c1e7ab39abf85 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 16:06:57 +0200 Subject: [PATCH 34/71] chore: provide navigation info to guards wrap call --- packages/router/src/navigationGuards.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 87897ad67..540cb14f1 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -174,7 +174,8 @@ export function guardToPromiseFn( record && record.instances[name!], to, from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next + __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next, + info ) ) let guardCall = Promise.resolve(guardReturn) From 83f47c71f441757d1212189cf4884f5f5401b000 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 17:40:33 +0200 Subject: [PATCH 35/71] chore: add info as last parameter at extractComponentsGuards --- packages/router/src/navigation-api/index.ts | 6 ++++-- packages/router/src/navigationGuards.ts | 4 ++-- packages/router/src/router.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 81a8a8818..503d132ab 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -143,6 +143,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'beforeRouteLeave', to, from, + undefined, navigationInfo ) @@ -174,6 +175,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'beforeRouteUpdate', to, from, + undefined, navigationInfo ) guards.push(canceledNavigationCheck) @@ -210,8 +212,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { 'beforeRouteEnter', to, from, - navigationInfo, - runWithContext + runWithContext, + navigationInfo ) guards.push(canceledNavigationCheck) await runGuardQueue(guards) diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 540cb14f1..66da0ba8f 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -236,8 +236,8 @@ export function extractComponentsGuards( guardType: GuardType, to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - info?: NavigationInformation, - runWithContext: (fn: () => T) => T = fn => fn() + runWithContext: (fn: () => T) => T = fn => fn(), + info?: NavigationInformation ) { const guards: Array<() => Promise> = [] diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index e26a96295..e398304f2 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -923,8 +923,8 @@ export function createRouter(options: RouterOptions): Router { 'beforeRouteEnter', to, from, - undefined, - runWithContext + runWithContext, + undefined ) guards.push(canceledNavigationCheck) From 912be85fd47b9da0ce5bd92251a3c04987060f22 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 14 Sep 2025 17:41:42 +0200 Subject: [PATCH 36/71] chore: use runGuardQueue logic from router at nav. api router --- packages/router/src/navigation-api/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 503d132ab..6a2bd1b8e 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -120,10 +120,12 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { : fn() } - async function runGuardQueue(guards: Lazy[]): Promise { - for (const guard of guards) { - await runWithContext(guard) - } + // TODO: type this as NavigationGuardReturn or similar instead of any + function runGuardQueue(guards: Lazy[]): Promise { + return guards.reduce( + (promise, guard) => promise.then(() => runWithContext(guard)), + Promise.resolve() + ) } let ready: boolean = false From 43b2aabd7c9e80e3424d762046cae0026e06a7f6 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 13:41:35 +0200 Subject: [PATCH 37/71] chore: expose navigation api abort signal for advance usage --- packages/router/src/history/common.ts | 10 ++++++++++ packages/router/src/navigation-api/index.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/router/src/history/common.ts b/packages/router/src/history/common.ts index ba7fc5e5f..c9a5e7ce6 100644 --- a/packages/router/src/history/common.ts +++ b/packages/router/src/history/common.ts @@ -50,12 +50,22 @@ export interface NavigationInformation { delta: number /** * True if the navigation was triggered by the browser back button. + * + * Note: available only with the new Navigation API. */ isBackBrowserButton?: boolean /** * True if the navigation was triggered by the browser forward button. + * + * Note: available only with the new Navigation API. */ isForwardBrowserButton?: boolean + /** + * AbortSignal that will be emitted when the navigation is aborted. + * + * Note: available only with the new Navigation API. + */ + signal?: AbortSignal } export interface NavigationCallback { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 6a2bd1b8e..b1cebd088 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -650,6 +650,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { delta, isBackBrowserButton: delta < 0, isForwardBrowserButton: delta > 0, + signal: event.signal, } } else if ( event.navigationType === 'push' || @@ -662,6 +663,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { : NavigationType.pop, direction: NavigationDirection.unknown, // No specific direction for push/replace. delta: event.navigationType === 'push' ? 1 : 0, + signal: event.signal, } } @@ -749,7 +751,8 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { } else if ( !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) ) { - triggerError(failure, to, from) + // just ignore errors caused by the cancellation of the navigation + triggerError(failure, to, from).catch(() => {}) } } finally { // update always, we'll have some race condition it the user clicks 2 links From 1b99c9b63f3f84665230d919a219ebad2477ddf5 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 14:01:24 +0200 Subject: [PATCH 38/71] chore: don't show navigation cancellation error at `handleCurrentEntryChange` --- packages/router/src/navigation-api/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index b1cebd088..0d0bbd7f8 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -265,13 +265,14 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { function triggerError( error: any, to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded + from: RouteLocationNormalizedLoaded, + silent = false ): Promise { markAsReady(error) const list = errorListeners.list() if (list.length) { list.forEach(handler => handler(error, to, from)) - } else { + } else if (!silent) { console.error('uncaught error during route navigation:') console.error(error) } @@ -752,7 +753,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) ) { // just ignore errors caused by the cancellation of the navigation - triggerError(failure, to, from).catch(() => {}) + triggerError(failure, to, from, true).catch(() => {}) } } finally { // update always, we'll have some race condition it the user clicks 2 links From 04352df9bc831e9aa9f8d93233bfc63ca9bae6f5 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 14:20:17 +0200 Subject: [PATCH 39/71] chore: check guards parameters call to fix current browser tests --- packages/router/src/navigationGuards.ts | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 66da0ba8f..95d552d3d 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -169,15 +169,27 @@ export function guardToPromiseFn( } // wrapping with Promise.resolve allows it to work with both async and sync guards - const guardReturn = runWithContext(() => - guard.call( - record && record.instances[name!], - to, - from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next, - info - ) - ) + const guardReturn = + guard.length > 3 + ? runWithContext(() => + guard.call( + record && record.instances[name!], + to, + from, + __DEV__ ? canOnlyBeCalledOnce(next, to, from, info) : next, + info + ) + ) + : runWithContext(() => + guard.call( + record && record.instances[name!], + to, + from, + __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next, + info + ) + ) + let guardCall = Promise.resolve(guardReturn) if (guard.length < 3) guardCall = guardCall.then(next) From 7c54940df8a357a9f445139c73d2a2dff45359d5 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 14:26:12 +0200 Subject: [PATCH 40/71] chore: don't provide info to `runWithContext` call --- packages/router/src/navigationGuards.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 95d552d3d..ebe160b46 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -185,8 +185,7 @@ export function guardToPromiseFn( record && record.instances[name!], to, from, - __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next, - info + __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next ) ) From afdb6e01ab56f893b51f36872a35d8e174dea693 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 18:46:01 +0200 Subject: [PATCH 41/71] feat: add native view transition built-in support --- packages/router/src/RouterView.ts | 9 +- packages/router/src/client-router.ts | 51 ++++++++ packages/router/src/navigation-api/index.ts | 77 ++++++++++- packages/router/src/router-factory.ts | 1 - packages/router/src/router.ts | 137 +++++++++++++++++++- packages/router/src/transition.ts | 27 ++++ 6 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 packages/router/src/client-router.ts delete mode 100644 packages/router/src/router-factory.ts create mode 100644 packages/router/src/transition.ts diff --git a/packages/router/src/RouterView.ts b/packages/router/src/RouterView.ts index a456c2b63..94be8119d 100644 --- a/packages/router/src/RouterView.ts +++ b/packages/router/src/RouterView.ts @@ -30,6 +30,7 @@ import { import { assign, isArray, isBrowser } from './utils' import { warn } from './warning' import { isSameRouteRecord } from './location' +import { transitionModeKey } from './transition' export interface RouterViewProps { name?: string @@ -62,6 +63,7 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ __DEV__ && warnDeprecatedUsage() const injectedRoute = inject(routerViewLocationKey)! + const transitionMode = inject(transitionModeKey)! const routeToDisplay = computed( () => props.route || injectedRoute.value ) @@ -199,8 +201,11 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ return ( // pass the vnode to the slot as a prop. // h and both accept vnodes - normalizeSlot(slots.default, { Component: component, route }) || - component + normalizeSlot(slots.default, { + Component: component, + route, + transitionMode, + }) || component ) } }, diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts new file mode 100644 index 000000000..41ff2beb0 --- /dev/null +++ b/packages/router/src/client-router.ts @@ -0,0 +1,51 @@ +import type { Router } from './router' +import type { + RouterApiOptions, + createNavigationApiRouter, +} from './navigation-api' +import type { RouterViewTransition, TransitionMode } from './transition' + +export interface ClientRouterOptions { + /** + * Factory function that creates a legacy router instance. + * Typically: () => createRouter({ history: createWebHistory(), routes }) + */ + legacy: { + factory: (transitionMode: TransitionMode) => Router + } + /** + * Options for the new Navigation API based router. + * If provided and the browser supports it, this will be used. + */ + navigationApi?: { + options: RouterApiOptions + } + /** + * Enable native View Transitions. + */ + viewTransition?: true | RouterViewTransition +} + +export function createClientRouter(options: ClientRouterOptions): Router { + let transitionMode: TransitionMode = 'auto' + + if ( + options?.viewTransition && + typeof document !== 'undefined' && + document.startViewTransition + ) { + transitionMode = 'view-transition' + } + + const useNavigationApi = + options.navigationApi && inBrowser && window.navigation + + if (useNavigationApi) { + return createNavigationApiRouter( + options.navigationApi!.options, + transitionMode + ) + } else { + return options.legacy.factory(transitionMode) + } +} diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 0d0bbd7f8..4f1356dcc 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1,4 +1,4 @@ -import type { App } from 'vue' +import { App, shallowReactive, unref } from 'vue' import { shallowReactive, shallowRef, unref } from 'vue' import { parseURL, @@ -59,13 +59,17 @@ import { RouterHistory, } from '../history/common' import { RouteRecordNormalized } from '../matcher/types' +import { TransitionMode, transitionModeKey } from '../transition' export interface RouterApiOptions extends Omit { base?: string location: string } -export function createNavigationApiRouter(options: RouterApiOptions): Router { +export function createNavigationApiRouter( + options: RouterApiOptions, + transitionMode: TransitionMode = 'auto' +): Router { const matcher = createRouterMatcher(options.routes, options) const parseQuery = options.parseQuery || originalParseQuery const stringifyQuery = options.stringifyQuery || originalStringifyQuery @@ -789,6 +793,10 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { replace: (to: RouteLocationRaw) => navigate(to, { replace: true }), } + let beforeResolveTransitionGuard: (() => void) | undefined + let afterEachTransitionGuard: (() => void) | undefined + let onErrorTransitionGuard: (() => void) | undefined + const router: Router = { currentRoute, listening: true, @@ -817,6 +825,70 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { onError: errorListeners.add, isReady, + enableViewTransition(options) { + beforeResolveTransitionGuard?.() + afterEachTransitionGuard?.() + onErrorTransitionGuard?.() + + if (typeof document === 'undefined' || !document.startViewTransition) { + return + } + + const defaultTransitionSetting = + options.transition?.defaultViewTransition ?? true + + let finishTransition: (() => void) | undefined + let abortTransition: (() => void) | undefined + + const resetTransitionState = () => { + finishTransition = undefined + abortTransition = undefined + } + + beforeResolveTransitionGuard = this.beforeResolve( + (to, from, next, info) => { + const transitionMode = + to.meta.viewTransition ?? defaultTransitionSetting + if ( + info?.isBackBrowserButton || + info?.isForwardBrowserButton || + transitionMode === false || + (transitionMode !== 'always' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches) + ) { + next(true) + return + } + + const promise = new Promise((resolve, reject) => { + finishTransition = resolve + abortTransition = reject + }) + + const transition = document.startViewTransition(() => promise) + + options.onStart?.(transition) + transition.finished + .then(() => options.onFinished?.(transition)) + .catch(() => options.onAborted?.(transition)) + .finally(resetTransitionState) + + next(true) + + return promise + } + ) + + afterEachTransitionGuard = this.afterEach(() => { + finishTransition?.() + }) + + onErrorTransitionGuard = this.onError((error, to, from) => { + abortTransition?.() + resetTransitionState() + }) + }, + install(app) { app.component('RouterLink', RouterLink) app.component('RouterView', RouterView) @@ -859,6 +931,7 @@ export function createNavigationApiRouter(options: RouterApiOptions): Router { app.provide(routerKey, router) app.provide(routeLocationKey, shallowReactive(reactiveRoute)) app.provide(routerViewLocationKey, currentRoute) + app.provide(transitionModeKey, transitionMode) const unmountApp = app.unmount installedApps.add(app) diff --git a/packages/router/src/router-factory.ts b/packages/router/src/router-factory.ts deleted file mode 100644 index 8f996c4bf..000000000 --- a/packages/router/src/router-factory.ts +++ /dev/null @@ -1 +0,0 @@ -export function createRouterFactory() {} diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index e398304f2..0cd48043d 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -69,6 +69,11 @@ import { addDevtools } from './devtools' import { _LiteralUnion } from './types/utils' import { RouteLocationAsRelativeTyped } from './typed-routes/route-location' import { RouteMap } from './typed-routes/route-map' +import { + RouterViewTransition, + TransitionMode, + transitionModeKey, +} from './transition' /** * Internal type to define an ErrorHandler @@ -205,6 +210,15 @@ export interface Router { */ listening: boolean + /** + * Enable native view transition. + * + * NOTE: will be a no-op if the browser does not support it. + * + * @param options The options to use. + */ + enableViewTransition(options: RouterViewTransition) + /** * Add a new {@link RouteRecordRaw | route record} as the child of an existing route. * @@ -216,6 +230,7 @@ export interface Router { parentName: NonNullable, route: RouteRecordRaw ): () => void + /** * Add a new {@link RouteRecordRaw | route record} to the router. * @@ -382,7 +397,10 @@ export interface Router { * * @param options - {@link RouterOptions} */ -export function createRouter(options: RouterOptions): Router { +export function createRouter( + options: RouterOptions, + transitionMode: TransitionMode = 'auto' +): Router { const matcher = createRouterMatcher(options.routes, options) const parseQuery = options.parseQuery || originalParseQuery const stringifyQuery = options.stringifyQuery || originalStringifyQuery @@ -1230,6 +1248,11 @@ export function createRouter(options: RouterOptions): Router { let started: boolean | undefined const installedApps = new Set() + let beforeResolveTransitionGuard: (() => void) | undefined + let afterEachTransitionGuard: (() => void) | undefined + let onErrorTransitionGuard: (() => void) | undefined + let popStateListener: (() => void) | undefined + const router: Router = { currentRoute, listening: true, @@ -1255,6 +1278,26 @@ export function createRouter(options: RouterOptions): Router { onError: errorListeners.add, isReady, + enableViewTransition(options) { + beforeResolveTransitionGuard?.() + afterEachTransitionGuard?.() + onErrorTransitionGuard?.() + if (popStateListener) { + window.removeEventListener('popstate', popStateListener) + } + + if (typeof document === 'undefined' || !document.startViewTransition) { + return + } + + ;[ + beforeResolveTransitionGuard, + afterEachTransitionGuard, + onErrorTransitionGuard, + popStateListener, + ] = enableViewTransition(this, options) + }, + install(app: App) { app.component('RouterLink', RouterLink) app.component('RouterView', RouterView) @@ -1293,6 +1336,7 @@ export function createRouter(options: RouterOptions): Router { app.provide(routerKey, router) app.provide(routeLocationKey, shallowReactive(reactiveRoute)) app.provide(routerViewLocationKey, currentRoute) + app.provide(transitionModeKey, transitionMode) const unmountApp = app.unmount installedApps.add(app) @@ -1356,3 +1400,94 @@ function extractChangingRecords( return [leavingRecords, updatingRecords, enteringRecords] } + +function isChangingPage( + to: RouteLocationNormalized, + from: RouteLocationNormalized +) { + if (to === from || from === START_LOCATION) { + return false + } + + // If route keys are different then it will result in a rerender + if (generateRouteKey(to) !== generateRouteKey(from)) { + return true + } + + const areComponentsSame = to.matched.every( + (comp, index) => + comp.components && + comp.components.default === from.matched[index]?.components?.default + ) + return !areComponentsSame +} + +function enableViewTransition(router: Router, options: RouterViewTransition) { + let transition: undefined | ViewTransition + let hasUAVisualTransition = false + let finishTransition: (() => void) | undefined + let abortTransition: (() => void) | undefined + + const defaultTransitionSetting = + options.transition?.defaultViewTransition ?? true + + const resetTransitionState = () => { + transition = undefined + hasUAVisualTransition = false + abortTransition = undefined + finishTransition = undefined + } + + function popStateListener(event: PopStateEvent) { + hasUAVisualTransition = event.hasUAVisualTransition + if (hasUAVisualTransition) { + transition?.skipTransition() + } + } + + window.addEventListener('popstate', popStateListener) + + const beforeResolveTransitionGuard = router.beforeResolve((to, from) => { + const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting + if ( + hasUAVisualTransition || + transitionMode === false || + (transitionMode !== 'always' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches) || + !isChangingPage(to, from) + ) { + return + } + + const promise = new Promise((resolve, reject) => { + finishTransition = resolve + abortTransition = reject + }) + + const transition = document.startViewTransition(() => promise) + + options.onStart?.(transition) + transition.finished + .then(() => options.onFinished?.(transition)) + .catch(() => options.onAborted?.(transition)) + .finally(resetTransitionState) + + return promise + }) + + const afterEachTransitionGuard = router.afterEach(() => { + finishTransition?.() + }) + + const onErrorTransitionGuard = router.onError(() => { + abortTransition?.() + resetTransitionState() + }) + + return [ + beforeResolveTransitionGuard, + afterEachTransitionGuard, + onErrorTransitionGuard, + popStateListener, + ] +} diff --git a/packages/router/src/transition.ts b/packages/router/src/transition.ts new file mode 100644 index 000000000..129243a4f --- /dev/null +++ b/packages/router/src/transition.ts @@ -0,0 +1,27 @@ +import type { InjectionKey } from 'vue' +import { inject } from 'vue' + +export type TransitionMode = 'auto' | 'view-transition' + +export const transitionModeKey: InjectionKey = Symbol( + 'vue-router-transition-mode' +) + +export function injectTransitionMode(): TransitionMode { + return inject(transitionModeKey, 'auto') +} + +export interface RouterViewTransition { + defaultTransitionView?: boolean | 'always' + /** Hook called right after the view transition starts */ + onStart?: (transition: ViewTransition) => void + /** Hook called when the view transition animation is finished */ + onFinished?: (transition: ViewTransition) => void + /** Hook called if the transition is aborted */ + onAborted?: (transition: ViewTransition) => void +} + +export interface RouterTransitionOptions { + mode: T + options?: T extends 'view-transition' ? ViewTransitionOptions : undefined +} From 538ce7b9afd2d52f0de54c63e88ec3728ec1cf54 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:05:33 +0200 Subject: [PATCH 42/71] chore: add routes utils and expose new stuff --- packages/router/src/index.ts | 5 +++ packages/router/src/navigation-api/index.ts | 4 +- packages/router/src/router.ts | 25 +----------- packages/router/src/transition.ts | 5 --- packages/router/src/utils/routes.ts | 42 +++++++++++++++++++++ 5 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 packages/router/src/utils/routes.ts diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index b816fb54d..0f081a692 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -141,6 +141,10 @@ export { createRouter } from './router' export { createNavigationApiRouter } from './navigation-api' export type { Router, RouterOptions, RouterScrollBehavior } from './router' export type { RouterApiOptions } from './navigation-api' +export type { TransitionMode, RouterViewTransition } from './transition' +export type { ClientRouterOptions } from './client-router' +export { injectTransitionMode, transitionModeKey } from './transition' +export { createClientRouter } from './client-router' export { NavigationFailureType, isNavigationFailure } from './errors' export type { @@ -162,6 +166,7 @@ export type { UseLinkReturn, } from './RouterLink' export { RouterView } from './RouterView' +export { isChangingPage } from './utils/routes' export type { RouterViewProps } from './RouterView' export type { TypesConfig } from './config' diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 4f1356dcc..9a5cf4167 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -60,6 +60,7 @@ import { } from '../history/common' import { RouteRecordNormalized } from '../matcher/types' import { TransitionMode, transitionModeKey } from '../transition' +import { isChangingPage } from '../utils/routes' export interface RouterApiOptions extends Omit { base?: string @@ -854,7 +855,8 @@ export function createNavigationApiRouter( info?.isForwardBrowserButton || transitionMode === false || (transitionMode !== 'always' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches) + window.matchMedia('(prefers-reduced-motion: reduce)').matches) || + !isChangingPage(to, from) ) { next(true) return diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 0cd48043d..0e057a8e9 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -66,7 +66,6 @@ import { routerViewLocationKey, } from './injectionSymbols' import { addDevtools } from './devtools' -import { _LiteralUnion } from './types/utils' import { RouteLocationAsRelativeTyped } from './typed-routes/route-location' import { RouteMap } from './typed-routes/route-map' import { @@ -74,6 +73,7 @@ import { TransitionMode, transitionModeKey, } from './transition' +import { isChangingPage } from './utils/routes' /** * Internal type to define an ErrorHandler @@ -1251,7 +1251,7 @@ export function createRouter( let beforeResolveTransitionGuard: (() => void) | undefined let afterEachTransitionGuard: (() => void) | undefined let onErrorTransitionGuard: (() => void) | undefined - let popStateListener: (() => void) | undefined + let popStateListener: ((event: PopStateEvent) => void) | undefined const router: Router = { currentRoute, @@ -1401,27 +1401,6 @@ function extractChangingRecords( return [leavingRecords, updatingRecords, enteringRecords] } -function isChangingPage( - to: RouteLocationNormalized, - from: RouteLocationNormalized -) { - if (to === from || from === START_LOCATION) { - return false - } - - // If route keys are different then it will result in a rerender - if (generateRouteKey(to) !== generateRouteKey(from)) { - return true - } - - const areComponentsSame = to.matched.every( - (comp, index) => - comp.components && - comp.components.default === from.matched[index]?.components?.default - ) - return !areComponentsSame -} - function enableViewTransition(router: Router, options: RouterViewTransition) { let transition: undefined | ViewTransition let hasUAVisualTransition = false diff --git a/packages/router/src/transition.ts b/packages/router/src/transition.ts index 129243a4f..37edc9396 100644 --- a/packages/router/src/transition.ts +++ b/packages/router/src/transition.ts @@ -20,8 +20,3 @@ export interface RouterViewTransition { /** Hook called if the transition is aborted */ onAborted?: (transition: ViewTransition) => void } - -export interface RouterTransitionOptions { - mode: T - options?: T extends 'view-transition' ? ViewTransitionOptions : undefined -} diff --git a/packages/router/src/utils/routes.ts b/packages/router/src/utils/routes.ts new file mode 100644 index 000000000..e313c44f3 --- /dev/null +++ b/packages/router/src/utils/routes.ts @@ -0,0 +1,42 @@ +import { RouteLocationNormalized } from '../typed-routes' +import { START_LOCATION } from '../index' + +// from Nuxt +const ROUTE_KEY_PARENTHESES_RE = /(:\w+)\([^)]+\)/g +const ROUTE_KEY_SYMBOLS_RE = /(:\w+)[?+*]/g +const ROUTE_KEY_NORMAL_RE = /:\w+/g +// TODO: consider refactoring into single utility +// See https://github.com/nuxt/nuxt/tree/main/packages/nuxt/src/pages/runtime/utils.ts#L8-L19 +function generateRouteKey(route: RouteLocationNormalized) { + const source = + route?.meta.key ?? + route.path + .replace(ROUTE_KEY_PARENTHESES_RE, '$1') + .replace(ROUTE_KEY_SYMBOLS_RE, '$1') + .replace( + ROUTE_KEY_NORMAL_RE, + r => route.params[r.slice(1)]?.toString() || '' + ) + return typeof source === 'function' ? source(route) : source +} + +export function isChangingPage( + to: RouteLocationNormalized, + from: RouteLocationNormalized +) { + if (to === from || from === START_LOCATION) { + return false + } + + // If route keys are different then it will result in a rerender + if (generateRouteKey(to) !== generateRouteKey(from)) { + return true + } + + const areComponentsSame = to.matched.every( + (comp, index) => + comp.components && + comp.components.default === from.matched[index]?.components?.default + ) + return !areComponentsSame +} From bbd2bbde56a61dd602235f670993db5b13732658 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:09:47 +0200 Subject: [PATCH 43/71] chore: fix types --- packages/router/src/navigation-api/index.ts | 3 +-- packages/router/src/router.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 9a5cf4167..1d1169687 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -835,8 +835,7 @@ export function createNavigationApiRouter( return } - const defaultTransitionSetting = - options.transition?.defaultViewTransition ?? true + const defaultTransitionSetting = options.defaultViewTransition ?? true let finishTransition: (() => void) | undefined let abortTransition: (() => void) | undefined diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 0e057a8e9..9e0aa4b05 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1407,8 +1407,7 @@ function enableViewTransition(router: Router, options: RouterViewTransition) { let finishTransition: (() => void) | undefined let abortTransition: (() => void) | undefined - const defaultTransitionSetting = - options.transition?.defaultViewTransition ?? true + const defaultTransitionSetting = options?.defaultViewTransition ?? true const resetTransitionState = () => { transition = undefined @@ -1468,5 +1467,5 @@ function enableViewTransition(router: Router, options: RouterViewTransition) { afterEachTransitionGuard, onErrorTransitionGuard, popStateListener, - ] + ] as const } From cecb1edad7450a52d404c2ab509969367c4aac7f Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:12:44 +0200 Subject: [PATCH 44/71] chore: . --- packages/router/src/transition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/transition.ts b/packages/router/src/transition.ts index 37edc9396..29d4ce17b 100644 --- a/packages/router/src/transition.ts +++ b/packages/router/src/transition.ts @@ -12,7 +12,7 @@ export function injectTransitionMode(): TransitionMode { } export interface RouterViewTransition { - defaultTransitionView?: boolean | 'always' + defaultViewTransition?: boolean | 'always' /** Hook called right after the view transition starts */ onStart?: (transition: ViewTransition) => void /** Hook called when the view transition animation is finished */ From 19847b2ebf6b20312abdef22610d180e30e8035f Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:14:56 +0200 Subject: [PATCH 45/71] chore: add return type to enableViewTransition --- packages/router/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 9e0aa4b05..c7f4f2ba7 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -217,7 +217,7 @@ export interface Router { * * @param options The options to use. */ - enableViewTransition(options: RouterViewTransition) + enableViewTransition(options: RouterViewTransition): void /** * Add a new {@link RouteRecordRaw | route record} as the child of an existing route. From d39977c68b2152a4acb9dfe4e4cc8df5dae51d48 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:17:05 +0200 Subject: [PATCH 46/71] chore: fix import at new nav. api router --- packages/router/src/navigation-api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 1d1169687..1b6095db7 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1,4 +1,4 @@ -import { App, shallowReactive, unref } from 'vue' +import type { App } from 'vue' import { shallowReactive, shallowRef, unref } from 'vue' import { parseURL, From 43e16728c8aaae9f967794222caa94b57632d593 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:21:13 +0200 Subject: [PATCH 47/71] chore: fix imports at client-router --- packages/router/src/client-router.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts index 41ff2beb0..e144704d7 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/client-router.ts @@ -1,9 +1,8 @@ import type { Router } from './router' -import type { - RouterApiOptions, - createNavigationApiRouter, -} from './navigation-api' +import type { RouterApiOptions } from './navigation-api' import type { RouterViewTransition, TransitionMode } from './transition' +import { createNavigationApiRouter } from './navigation-api' +import { isBrowser } from './utils' export interface ClientRouterOptions { /** @@ -38,7 +37,7 @@ export function createClientRouter(options: ClientRouterOptions): Router { } const useNavigationApi = - options.navigationApi && inBrowser && window.navigation + options.navigationApi && isBrowser && window.navigation if (useNavigationApi) { return createNavigationApiRouter( From 29fdfb4829c23230befb9a7fdbcdcde663eb8589 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:25:21 +0200 Subject: [PATCH 48/71] chore: refactor logic to detect view transition --- packages/router/src/client-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts index e144704d7..121f83a3b 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/client-router.ts @@ -31,7 +31,7 @@ export function createClientRouter(options: ClientRouterOptions): Router { if ( options?.viewTransition && typeof document !== 'undefined' && - document.startViewTransition + !!document.startViewTransition ) { transitionMode = 'view-transition' } From ce3356492c0a9a4f943ff8c603deb12031024c4f Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:30:46 +0200 Subject: [PATCH 49/71] chore: change symbol for transition mode --- packages/router/src/transition.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router/src/transition.ts b/packages/router/src/transition.ts index 29d4ce17b..427cc8499 100644 --- a/packages/router/src/transition.ts +++ b/packages/router/src/transition.ts @@ -3,9 +3,9 @@ import { inject } from 'vue' export type TransitionMode = 'auto' | 'view-transition' -export const transitionModeKey: InjectionKey = Symbol( - 'vue-router-transition-mode' -) +export const transitionModeKey = Symbol( + __DEV__ ? 'transition mode' : '' +) as InjectionKey export function injectTransitionMode(): TransitionMode { return inject(transitionModeKey, 'auto') From 51fbf74d8ebc07aea51931c165f9262899e60e47 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:47:04 +0200 Subject: [PATCH 50/71] chore: update TS (5.8.2), api extractor (7.52.13), tsdoc (0.28.13) adn tsdoc-plugin-md (4.8.1) --- package.json | 6 +- packages/router/package.json | 2 +- packages/router/src/router.ts | 1 + pnpm-lock.yaml | 312 ++++++++++++++++++++-------------- 4 files changed, 185 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index 0332ae857..0cc081fca 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "prettier": "^3.5.3", "semver": "^7.7.1", "simple-git-hooks": "^2.13.0", - "typedoc": "^0.26.11", - "typedoc-plugin-markdown": "^4.2.10", - "typescript": "~5.6.3", + "typedoc": "^0.28.13", + "typedoc-plugin-markdown": "^4.8.1", + "typescript": "~5.8.2", "vitest": "^2.1.9" }, "simple-git-hooks": { diff --git a/packages/router/package.json b/packages/router/package.json index b738bd65c..19ce2a3bd 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -117,7 +117,7 @@ "@vue/devtools-api": "^6.6.4" }, "devDependencies": { - "@microsoft/api-extractor": "^7.48.0", + "@microsoft/api-extractor": "^7.52.13", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-node-resolve": "^15.3.1", diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index c7f4f2ba7..9c532c53c 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -396,6 +396,7 @@ export interface Router { * Creates a Router instance that can be used by a Vue app. * * @param options - {@link RouterOptions} + * @param transitionMode The new transition mode option. */ export function createRouter( options: RouterOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3657d9987..53665e518 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,14 +51,14 @@ importers: specifier: ^2.13.0 version: 2.13.0 typedoc: - specifier: ^0.26.11 - version: 0.26.11(typescript@5.6.3) + specifier: ^0.28.13 + version: 0.28.13(typescript@5.8.3) typedoc-plugin-markdown: - specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + specifier: ^4.8.1 + version: 4.8.1(typedoc@0.28.13(typescript@5.8.3)) typescript: - specifier: ~5.6.3 - version: 5.6.3 + specifier: ~5.8.2 + version: 5.8.3 vitest: specifier: ^2.1.9 version: 2.1.9(@types/node@22.15.2)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@19.0.0)(terser@5.32.0) @@ -70,10 +70,10 @@ importers: version: 3.27.0 vitepress: specifier: 1.5.0 - version: 1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.6.3) + version: 1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.8.3) vitepress-translation-helper: specifier: ^0.2.1 - version: 0.2.1(vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.6.3))(vue@3.5.13(typescript@5.6.3)) + version: 0.2.1(vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3)) vue-router: specifier: workspace:* version: link:../router @@ -82,20 +82,20 @@ importers: dependencies: vue: specifier: ~3.5.13 - version: 3.5.13(typescript@5.6.3) + version: 3.5.13(typescript@5.8.3) devDependencies: '@types/node': specifier: ^20.17.31 version: 20.17.31 '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.3(vite@5.4.18(@types/node@20.17.31)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3)) + version: 5.2.3(vite@5.4.18(@types/node@20.17.31)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3)) '@vue/compiler-sfc': specifier: ~3.5.13 version: 3.5.13 '@vue/tsconfig': specifier: ^0.6.0 - version: 0.6.0(typescript@5.6.3)(vue@3.5.13(typescript@5.6.3)) + version: 0.6.0(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) vite: specifier: ^5.4.18 version: 5.4.18(@types/node@20.17.31)(terser@5.32.0) @@ -104,7 +104,7 @@ importers: version: link:../router vue-tsc: specifier: ^2.2.10 - version: 2.2.10(typescript@5.6.3) + version: 2.2.10(typescript@5.8.3) packages/router: dependencies: @@ -113,8 +113,8 @@ importers: version: 6.6.4 devDependencies: '@microsoft/api-extractor': - specifier: ^7.48.0 - version: 7.48.0(@types/node@22.15.2) + specifier: ^7.52.13 + version: 7.52.13(@types/node@22.15.2) '@rollup/plugin-alias': specifier: ^5.1.1 version: 5.1.1(rollup@3.29.5) @@ -141,13 +141,13 @@ importers: version: 2.3.32 '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.3(vite@5.4.18(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3)) + version: 5.2.3(vite@5.4.18(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3)) '@vue/compiler-sfc': specifier: ~3.5.13 version: 3.5.13 '@vue/server-renderer': specifier: ~3.5.13 - version: 3.5.13(vue@3.5.13(typescript@5.6.3)) + version: 3.5.13(vue@3.5.13(typescript@5.8.3)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -192,13 +192,13 @@ importers: version: 4.0.0 rollup-plugin-typescript2: specifier: ^0.36.0 - version: 0.36.0(rollup@3.29.5)(typescript@5.6.3) + version: 0.36.0(rollup@3.29.5)(typescript@5.8.3) vite: specifier: ^5.4.18 version: 5.4.18(@types/node@22.15.2)(terser@5.32.0) vue: specifier: ~3.5.13 - version: 3.5.13(typescript@5.6.3) + version: 3.5.13(typescript@5.8.3) packages: @@ -471,6 +471,9 @@ packages: cpu: [x64] os: [win32] + '@gerrit0/mini-shiki@3.12.2': + resolution: {integrity: sha512-HKZPmO8OSSAAo20H2B3xgJdxZaLTwtlMwxg0967scnrDlPwe6j5+ULGHyIqwgTbFCn9yv/ff8CmfWZLE9YKBzA==} + '@hutson/parse-repository-url@3.0.2': resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} @@ -481,6 +484,14 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -516,11 +527,11 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@microsoft/api-extractor-model@7.30.0': - resolution: {integrity: sha512-26/LJZBrsWDKAkOWRiQbdVgcfd1F3nyJnAiJzsAgpouPk7LtOIj7PK9aJtBaw/pUXrkotEg27RrT+Jm/q0bbug==} + '@microsoft/api-extractor-model@7.30.7': + resolution: {integrity: sha512-TBbmSI2/BHpfR9YhQA7nH0nqVmGgJ0xH0Ex4D99/qBDAUpnhA2oikGmdXanbw9AWWY/ExBYIpkmY8dBHdla3YQ==} - '@microsoft/api-extractor@7.48.0': - resolution: {integrity: sha512-FMFgPjoilMUWeZXqYRlJ3gCVRhB7WU/HN88n8OLqEsmsG4zBdX/KQdtJfhq95LQTQ++zfu0Em1LLb73NqRCLYQ==} + '@microsoft/api-extractor@7.52.13': + resolution: {integrity: sha512-K6/bBt8zZfn9yc06gNvA+/NlBGJC/iJlObpdufXHEJtqcD4Dln4ITCLZpwP3DNZ5NyBFeTkKdv596go3V72qlA==} hasBin: true '@microsoft/tsdoc-config@0.17.1': @@ -706,8 +717,8 @@ packages: cpu: [x64] os: [win32] - '@rushstack/node-core-library@5.10.0': - resolution: {integrity: sha512-2pPLCuS/3x7DCd7liZkqOewGM0OzLyCacdvOe8j6Yrx9LkETGnxul1t7603bIaB8nUAooORcct9fFDOQMbWAgw==} + '@rushstack/node-core-library@5.14.0': + resolution: {integrity: sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg==} peerDependencies: '@types/node': '*' peerDependenciesMeta: @@ -717,16 +728,16 @@ packages: '@rushstack/rig-package@0.5.3': resolution: {integrity: sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==} - '@rushstack/terminal@0.14.3': - resolution: {integrity: sha512-csXbZsAdab/v8DbU1sz7WC2aNaKArcdS/FPmXMOXEj/JBBZMvDK0+1b4Qao0kkG0ciB1Qe86/Mb68GjH6/TnMw==} + '@rushstack/terminal@0.16.0': + resolution: {integrity: sha512-WEvNuKkoR1PXorr9SxO0dqFdSp1BA+xzDrIm/Bwlc5YHg2FFg6oS+uCTYjerOhFuqCW+A3vKBm6EmKWSHfgx/A==} peerDependencies: '@types/node': '*' peerDependenciesMeta: '@types/node': optional: true - '@rushstack/ts-command-line@4.23.1': - resolution: {integrity: sha512-40jTmYoiu/xlIpkkRsVfENtBq4CW3R4azbL0Vmda+fMwHWqss6wwf/Cy/UJmMqIzpfYc2OTnjYP1ZLD3CmyeCA==} + '@rushstack/ts-command-line@5.0.3': + resolution: {integrity: sha512-bgPhQEqLVv/2hwKLYv/XvsTWNZ9B/+X1zJ7WgQE9rO5oiLzrOZvkIW4pk13yOQBhHyjcND5qMOa6p83t+Z66iQ==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -740,12 +751,27 @@ packages: '@shikijs/engine-oniguruma@1.23.1': resolution: {integrity: sha512-KQ+lgeJJ5m2ISbUZudLR1qHeH3MnSs2mjFg7bnencgs5jDVPeJ2NVDJ3N5ZHbcTsOIh0qIueyAJnwg7lg7kwXQ==} + '@shikijs/engine-oniguruma@3.12.2': + resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} + + '@shikijs/langs@3.12.2': + resolution: {integrity: sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==} + + '@shikijs/themes@3.12.2': + resolution: {integrity: sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==} + '@shikijs/transformers@1.23.1': resolution: {integrity: sha512-yQ2Cn0M9i46p30KwbyIzLvKDk+dQNU+lj88RGO0XEj54Hn4Cof1bZoDb9xBRWxFE4R8nmK63w7oHnJwvOtt0NQ==} '@shikijs/types@1.23.1': resolution: {integrity: sha512-98A5hGyEhzzAgQh2dAeHKrWW4HfCMeoFER2z16p5eJ+vmPeF6lZ/elEne6/UCU551F/WqkopqRsr1l2Yu6+A0g==} + '@shikijs/types@3.12.2': + resolution: {integrity: sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shikijs/vscode-textmate@9.3.0': resolution: {integrity: sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==} @@ -1862,9 +1888,9 @@ packages: resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} engines: {node: '>=14.14'} - fs-extra@7.0.1: - resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} - engines: {node: '>=6 <7 || >=8'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2276,9 +2302,6 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -2524,8 +2547,9 @@ packages: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} - minimatch@3.0.8: - resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3406,26 +3430,26 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} - typedoc-plugin-markdown@4.2.10: - resolution: {integrity: sha512-PLX3pc1/7z13UJm4TDE9vo9jWGcClFUErXXtd5LdnoLjV6mynPpqZLU992DwMGFSRqJFZeKbVyqlNNeNHnk2tQ==} + typedoc-plugin-markdown@4.8.1: + resolution: {integrity: sha512-ug7fc4j0SiJxSwBGLncpSo8tLvrT9VONvPUQqQDTKPxCoFQBADLli832RGPtj6sfSVJebNSrHZQRUdEryYH/7g==} engines: {node: '>= 18'} peerDependencies: - typedoc: 0.26.x + typedoc: 0.28.x - typedoc@0.26.11: - resolution: {integrity: sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==} - engines: {node: '>= 18'} + typedoc@0.28.13: + resolution: {integrity: sha512-dNWY8msnYB2a+7Audha+aTF1Pu3euiE7ySp53w8kEsXoYw7dMouV5A1UsTUY345aB152RHnmRMDiovuBi7BD+w==} + engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x - typescript@5.4.2: - resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} hasBin: true - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -3462,10 +3486,6 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -3752,16 +3772,16 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.6.1: - resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} - engines: {node: '>= 14'} - hasBin: true - yaml@2.7.1: resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} engines: {node: '>= 14'} hasBin: true + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.4: resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} engines: {node: '>=10'} @@ -4026,6 +4046,14 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@gerrit0/mini-shiki@3.12.2': + dependencies: + '@shikijs/engine-oniguruma': 3.12.2 + '@shikijs/langs': 3.12.2 + '@shikijs/themes': 3.12.2 + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + '@hutson/parse-repository-url@3.0.2': {} '@iconify-json/simple-icons@1.2.13': @@ -4034,6 +4062,12 @@ snapshots: '@iconify/types@2.0.0': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4075,29 +4109,29 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@microsoft/api-extractor-model@7.30.0(@types/node@22.15.2)': + '@microsoft/api-extractor-model@7.30.7(@types/node@22.15.2)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.10.0(@types/node@22.15.2) + '@rushstack/node-core-library': 5.14.0(@types/node@22.15.2) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.48.0(@types/node@22.15.2)': + '@microsoft/api-extractor@7.52.13(@types/node@22.15.2)': dependencies: - '@microsoft/api-extractor-model': 7.30.0(@types/node@22.15.2) + '@microsoft/api-extractor-model': 7.30.7(@types/node@22.15.2) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.10.0(@types/node@22.15.2) + '@rushstack/node-core-library': 5.14.0(@types/node@22.15.2) '@rushstack/rig-package': 0.5.3 - '@rushstack/terminal': 0.14.3(@types/node@22.15.2) - '@rushstack/ts-command-line': 4.23.1(@types/node@22.15.2) + '@rushstack/terminal': 0.16.0(@types/node@22.15.2) + '@rushstack/ts-command-line': 5.0.3(@types/node@22.15.2) lodash: 4.17.21 - minimatch: 3.0.8 + minimatch: 10.0.3 resolve: 1.22.8 semver: 7.5.4 source-map: 0.6.1 - typescript: 5.4.2 + typescript: 5.8.2 transitivePeerDependencies: - '@types/node' @@ -4247,12 +4281,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.27.4': optional: true - '@rushstack/node-core-library@5.10.0(@types/node@22.15.2)': + '@rushstack/node-core-library@5.14.0(@types/node@22.15.2)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) ajv-formats: 3.0.1(ajv@8.13.0) - fs-extra: 7.0.1 + fs-extra: 11.3.1 import-lazy: 4.0.0 jju: 1.4.0 resolve: 1.22.8 @@ -4265,16 +4299,16 @@ snapshots: resolve: 1.22.8 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.14.3(@types/node@22.15.2)': + '@rushstack/terminal@0.16.0(@types/node@22.15.2)': dependencies: - '@rushstack/node-core-library': 5.10.0(@types/node@22.15.2) + '@rushstack/node-core-library': 5.14.0(@types/node@22.15.2) supports-color: 8.1.1 optionalDependencies: '@types/node': 22.15.2 - '@rushstack/ts-command-line@4.23.1(@types/node@22.15.2)': + '@rushstack/ts-command-line@5.0.3(@types/node@22.15.2)': dependencies: - '@rushstack/terminal': 0.14.3(@types/node@22.15.2) + '@rushstack/terminal': 0.16.0(@types/node@22.15.2) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -4303,6 +4337,19 @@ snapshots: '@shikijs/types': 1.23.1 '@shikijs/vscode-textmate': 9.3.0 + '@shikijs/engine-oniguruma@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + + '@shikijs/themes@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + '@shikijs/transformers@1.23.1': dependencies: shiki: 1.23.1 @@ -4312,6 +4359,13 @@ snapshots: '@shikijs/vscode-textmate': 9.3.0 '@types/hast': 3.0.4 + '@shikijs/types@3.12.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@shikijs/vscode-textmate@9.3.0': {} '@sindresorhus/merge-streams@2.3.0': {} @@ -4407,20 +4461,20 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue@5.2.0(vite@5.4.11(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.0(vite@5.4.11(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3))': dependencies: vite: 5.4.11(@types/node@22.15.2)(terser@5.32.0) - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) - '@vitejs/plugin-vue@5.2.3(vite@5.4.18(@types/node@20.17.31)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.3(vite@5.4.18(@types/node@20.17.31)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3))': dependencies: vite: 5.4.18(@types/node@20.17.31)(terser@5.32.0) - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) - '@vitejs/plugin-vue@5.2.3(vite@5.4.18(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.3(vite@5.4.18(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3))': dependencies: vite: 5.4.18(@types/node@22.15.2)(terser@5.32.0) - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.15.2)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@19.0.0)(terser@5.32.0))': dependencies: @@ -4558,7 +4612,7 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/language-core@2.2.10(typescript@5.6.3)': + '@vue/language-core@2.2.10(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.12 '@vue/compiler-dom': 3.5.13 @@ -4569,7 +4623,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 5.6.3 + typescript: 5.8.3 '@vue/reactivity@3.5.13': dependencies: @@ -4587,11 +4641,11 @@ snapshots: '@vue/shared': 3.5.13 csstype: 3.1.3 - '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.6.3))': + '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))': dependencies: '@vue/compiler-ssr': 3.5.13 '@vue/shared': 3.5.13 - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) '@vue/shared@3.5.13': {} @@ -4600,26 +4654,26 @@ snapshots: js-beautify: 1.15.1 vue-component-type-helpers: 2.0.21 - '@vue/tsconfig@0.6.0(typescript@5.6.3)(vue@3.5.13(typescript@5.6.3))': + '@vue/tsconfig@0.6.0(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))': optionalDependencies: - typescript: 5.6.3 - vue: 3.5.13(typescript@5.6.3) + typescript: 5.8.3 + vue: 3.5.13(typescript@5.8.3) - '@vueuse/core@11.3.0(vue@3.5.13(typescript@5.6.3))': + '@vueuse/core@11.3.0(vue@3.5.13(typescript@5.8.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 11.3.0 - '@vueuse/shared': 11.3.0(vue@3.5.13(typescript@5.6.3)) - vue-demi: 0.14.10(vue@3.5.13(typescript@5.6.3)) + '@vueuse/shared': 11.3.0(vue@3.5.13(typescript@5.8.3)) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) transitivePeerDependencies: - '@vue/composition-api' - vue - '@vueuse/integrations@11.3.0(axios@1.7.7)(focus-trap@7.6.2)(vue@3.5.13(typescript@5.6.3))': + '@vueuse/integrations@11.3.0(axios@1.7.7)(focus-trap@7.6.2)(vue@3.5.13(typescript@5.8.3))': dependencies: - '@vueuse/core': 11.3.0(vue@3.5.13(typescript@5.6.3)) - '@vueuse/shared': 11.3.0(vue@3.5.13(typescript@5.6.3)) - vue-demi: 0.14.10(vue@3.5.13(typescript@5.6.3)) + '@vueuse/core': 11.3.0(vue@3.5.13(typescript@5.8.3)) + '@vueuse/shared': 11.3.0(vue@3.5.13(typescript@5.8.3)) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) optionalDependencies: axios: 1.7.7 focus-trap: 7.6.2 @@ -4629,9 +4683,9 @@ snapshots: '@vueuse/metadata@11.3.0': {} - '@vueuse/shared@11.3.0(vue@3.5.13(typescript@5.6.3))': + '@vueuse/shared@11.3.0(vue@3.5.13(typescript@5.8.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.13(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -5505,11 +5559,11 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 - fs-extra@7.0.1: + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 + jsonfile: 6.1.0 + universalify: 2.0.1 fs.realpath@1.0.0: {} @@ -5953,10 +6007,6 @@ snapshots: json-stringify-safe@5.0.1: {} - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -6228,9 +6278,9 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimatch@3.0.8: + minimatch@10.0.3: dependencies: - brace-expansion: 1.1.11 + '@isaacs/brace-expansion': 5.0.0 minimatch@3.1.2: dependencies: @@ -6739,7 +6789,7 @@ snapshots: rollup-plugin-analyzer@4.0.0: {} - rollup-plugin-typescript2@0.36.0(rollup@3.29.5)(typescript@5.6.3): + rollup-plugin-typescript2@0.36.0(rollup@3.29.5)(typescript@5.8.3): dependencies: '@rollup/pluginutils': 4.2.1 find-cache-dir: 3.3.2 @@ -6747,7 +6797,7 @@ snapshots: rollup: 3.29.5 semver: 7.6.3 tslib: 2.8.1 - typescript: 5.6.3 + typescript: 5.8.3 rollup@3.29.5: optionalDependencies: @@ -7148,22 +7198,22 @@ snapshots: type-fest@0.8.1: {} - typedoc-plugin-markdown@4.2.10(typedoc@0.26.11(typescript@5.6.3)): + typedoc-plugin-markdown@4.8.1(typedoc@0.28.13(typescript@5.8.3)): dependencies: - typedoc: 0.26.11(typescript@5.6.3) + typedoc: 0.28.13(typescript@5.8.3) - typedoc@0.26.11(typescript@5.6.3): + typedoc@0.28.13(typescript@5.8.3): dependencies: + '@gerrit0/mini-shiki': 3.12.2 lunr: 2.3.9 markdown-it: 14.1.0 minimatch: 9.0.5 - shiki: 1.23.1 - typescript: 5.6.3 - yaml: 2.6.1 + typescript: 5.8.3 + yaml: 2.8.1 - typescript@5.4.2: {} + typescript@5.8.2: {} - typescript@5.6.3: {} + typescript@5.8.3: {} uc.micro@2.1.0: {} @@ -7200,8 +7250,6 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - universalify@0.1.2: {} - universalify@0.2.0: {} universalify@2.0.1: {} @@ -7286,16 +7334,16 @@ snapshots: fsevents: 2.3.3 terser: 5.32.0 - vitepress-translation-helper@0.2.1(vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.6.3))(vue@3.5.13(typescript@5.6.3)): + vitepress-translation-helper@0.2.1(vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3)): dependencies: minimist: 1.2.8 simple-git: 3.27.0 - vitepress: 1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.6.3) - vue: 3.5.13(typescript@5.6.3) + vitepress: 1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.8.3) + vue: 3.5.13(typescript@5.8.3) transitivePeerDependencies: - supports-color - vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.6.3): + vitepress@1.5.0(@algolia/client-search@5.15.0)(@types/node@22.15.2)(axios@1.7.7)(postcss@8.4.49)(search-insights@2.17.1)(terser@5.32.0)(typescript@5.8.3): dependencies: '@docsearch/css': 3.8.0 '@docsearch/js': 3.8.0(@algolia/client-search@5.15.0)(search-insights@2.17.1) @@ -7304,17 +7352,17 @@ snapshots: '@shikijs/transformers': 1.23.1 '@shikijs/types': 1.23.1 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.0(vite@5.4.11(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.6.3)) + '@vitejs/plugin-vue': 5.2.0(vite@5.4.11(@types/node@22.15.2)(terser@5.32.0))(vue@3.5.13(typescript@5.8.3)) '@vue/devtools-api': 7.6.4 '@vue/shared': 3.5.13 - '@vueuse/core': 11.3.0(vue@3.5.13(typescript@5.6.3)) - '@vueuse/integrations': 11.3.0(axios@1.7.7)(focus-trap@7.6.2)(vue@3.5.13(typescript@5.6.3)) + '@vueuse/core': 11.3.0(vue@3.5.13(typescript@5.8.3)) + '@vueuse/integrations': 11.3.0(axios@1.7.7)(focus-trap@7.6.2)(vue@3.5.13(typescript@5.8.3)) focus-trap: 7.6.2 mark.js: 8.11.1 minisearch: 7.1.1 shiki: 1.23.1 vite: 5.4.11(@types/node@22.15.2)(terser@5.32.0) - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) optionalDependencies: postcss: 8.4.49 transitivePeerDependencies: @@ -7387,25 +7435,25 @@ snapshots: vue-component-type-helpers@2.0.21: {} - vue-demi@0.14.10(vue@3.5.13(typescript@5.6.3)): + vue-demi@0.14.10(vue@3.5.13(typescript@5.8.3)): dependencies: - vue: 3.5.13(typescript@5.6.3) + vue: 3.5.13(typescript@5.8.3) - vue-tsc@2.2.10(typescript@5.6.3): + vue-tsc@2.2.10(typescript@5.8.3): dependencies: '@volar/typescript': 2.4.12 - '@vue/language-core': 2.2.10(typescript@5.6.3) - typescript: 5.6.3 + '@vue/language-core': 2.2.10(typescript@5.8.3) + typescript: 5.8.3 - vue@3.5.13(typescript@5.6.3): + vue@3.5.13(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.13 '@vue/compiler-sfc': 3.5.13 '@vue/runtime-dom': 3.5.13 - '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.6.3)) + '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.3)) '@vue/shared': 3.5.13 optionalDependencies: - typescript: 5.6.3 + typescript: 5.8.3 w3c-hr-time@1.0.2: dependencies: @@ -7492,10 +7540,10 @@ snapshots: yallist@4.0.0: {} - yaml@2.6.1: {} - yaml@2.7.1: {} + yaml@2.8.1: {} + yargs-parser@20.2.4: {} yargs-parser@20.2.9: {} From c3c58d360ae514a175fa6c45392c86db4ea7cb54 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 19:54:35 +0200 Subject: [PATCH 51/71] chore: check for window --- packages/router/src/client-router.ts | 5 ++++- packages/router/src/navigation-api/index.ts | 3 +++ packages/router/src/router.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts index 121f83a3b..74ae2a585 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/client-router.ts @@ -37,7 +37,10 @@ export function createClientRouter(options: ClientRouterOptions): Router { } const useNavigationApi = - options.navigationApi && isBrowser && window.navigation + options.navigationApi && + isBrowser && + typeof window !== 'undefined' && + window.navigation if (useNavigationApi) { return createNavigationApiRouter( diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 1b6095db7..5f83e0b08 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -71,6 +71,9 @@ export function createNavigationApiRouter( options: RouterApiOptions, transitionMode: TransitionMode = 'auto' ): Router { + if (typeof window === 'undefined' || !window.navigation) { + throw new Error('Navigation API is not supported in this environment.') + } const matcher = createRouterMatcher(options.routes, options) const parseQuery = options.parseQuery || originalParseQuery const stringifyQuery = options.stringifyQuery || originalStringifyQuery diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 9c532c53c..fcf528487 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1283,7 +1283,7 @@ export function createRouter( beforeResolveTransitionGuard?.() afterEachTransitionGuard?.() onErrorTransitionGuard?.() - if (popStateListener) { + if (typeof window !== 'undefined' && popStateListener) { window.removeEventListener('popstate', popStateListener) } From f16ded67d44e195d81ea7499a51d2785bc9afece Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 20:00:51 +0200 Subject: [PATCH 52/71] chore: inject transition mode correctly at router view --- packages/router/src/RouterView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router/src/RouterView.ts b/packages/router/src/RouterView.ts index 94be8119d..7a6b65e23 100644 --- a/packages/router/src/RouterView.ts +++ b/packages/router/src/RouterView.ts @@ -63,7 +63,7 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ __DEV__ && warnDeprecatedUsage() const injectedRoute = inject(routerViewLocationKey)! - const transitionMode = inject(transitionModeKey)! + const transitionMode = inject(transitionModeKey, 'auto')! const routeToDisplay = computed( () => props.route || injectedRoute.value ) From 8d541a302aed05a4d4cb91a7b0f4b9421e0e5dbc Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 20:21:29 +0200 Subject: [PATCH 53/71] chore: add transitionMode to RouterView definition --- packages/router/src/RouterView.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/router/src/RouterView.ts b/packages/router/src/RouterView.ts index 7a6b65e23..4d6bb682f 100644 --- a/packages/router/src/RouterView.ts +++ b/packages/router/src/RouterView.ts @@ -30,7 +30,7 @@ import { import { assign, isArray, isBrowser } from './utils' import { warn } from './warning' import { isSameRouteRecord } from './location' -import { transitionModeKey } from './transition' +import { TransitionMode, transitionModeKey } from './transition' export interface RouterViewProps { name?: string @@ -147,7 +147,11 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ matchedRoute && matchedRoute.components![currentName] if (!ViewComponent) { - return normalizeSlot(slots.default, { Component: ViewComponent, route }) + return normalizeSlot(slots.default, { + Component: ViewComponent, + route, + transitionMode, + }) } // props from route configuration @@ -233,9 +237,11 @@ export const RouterView = RouterViewImpl as unknown as { default?: ({ Component, route, + transitionMode, }: { Component: VNode route: RouteLocationNormalizedLoaded + transitionMode: TransitionMode }) => VNode[] } } From f577bbf88413ea3e31883400d2c5de918e798036 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 20:41:49 +0200 Subject: [PATCH 54/71] chore: check transition mode before enabling view transition --- packages/router/src/navigation-api/index.ts | 7 +++++++ packages/router/src/router.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 5f83e0b08..f794add4e 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -838,6 +838,13 @@ export function createNavigationApiRouter( return } + if (transitionMode !== 'view-transition') { + if (__DEV__) { + console.warn('Native View Transition is disabled in auto mode.') + } + return + } + const defaultTransitionSetting = options.defaultViewTransition ?? true let finishTransition: (() => void) | undefined diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index fcf528487..53ba5a112 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1291,6 +1291,13 @@ export function createRouter( return } + if (transitionMode !== 'view-transition') { + if (__DEV__) { + console.warn('Native View Transition is disabled in auto mode.') + } + return + } + ;[ beforeResolveTransitionGuard, afterEachTransitionGuard, From b53dfc908613d19e5e2554482a927501f76dd4a7 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 21:18:45 +0200 Subject: [PATCH 55/71] chore: use isReady to register native view transition at legacy router --- packages/router/src/navigation-api/index.ts | 11 ++++++-- packages/router/src/router.ts | 31 +++++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index f794add4e..546e8947c 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -801,6 +801,12 @@ export function createNavigationApiRouter( let afterEachTransitionGuard: (() => void) | undefined let onErrorTransitionGuard: (() => void) | undefined + function cleanupNativeViewTransition() { + beforeResolveTransitionGuard?.() + afterEachTransitionGuard?.() + onErrorTransitionGuard?.() + } + const router: Router = { currentRoute, listening: true, @@ -830,9 +836,7 @@ export function createNavigationApiRouter( isReady, enableViewTransition(options) { - beforeResolveTransitionGuard?.() - afterEachTransitionGuard?.() - onErrorTransitionGuard?.() + cleanupNativeViewTransition() if (typeof document === 'undefined' || !document.startViewTransition) { return @@ -953,6 +957,7 @@ export function createNavigationApiRouter( // invalidate the current navigation pendingLocation = START_LOCATION_NORMALIZED currentRoute.value = START_LOCATION_NORMALIZED + cleanupNativeViewTransition() started = false ready = false } diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 53ba5a112..7f88a8f3b 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1254,6 +1254,15 @@ export function createRouter( let onErrorTransitionGuard: (() => void) | undefined let popStateListener: ((event: PopStateEvent) => void) | undefined + function cleanupNativeViewTransition() { + beforeResolveTransitionGuard?.() + afterEachTransitionGuard?.() + onErrorTransitionGuard?.() + if (typeof window !== 'undefined' && popStateListener) { + window.removeEventListener('popstate', popStateListener) + } + } + const router: Router = { currentRoute, listening: true, @@ -1280,12 +1289,7 @@ export function createRouter( isReady, enableViewTransition(options) { - beforeResolveTransitionGuard?.() - afterEachTransitionGuard?.() - onErrorTransitionGuard?.() - if (typeof window !== 'undefined' && popStateListener) { - window.removeEventListener('popstate', popStateListener) - } + cleanupNativeViewTransition() if (typeof document === 'undefined' || !document.startViewTransition) { return @@ -1298,12 +1302,14 @@ export function createRouter( return } - ;[ - beforeResolveTransitionGuard, - afterEachTransitionGuard, - onErrorTransitionGuard, - popStateListener, - ] = enableViewTransition(this, options) + this.isReady().then(() => { + ;[ + beforeResolveTransitionGuard, + afterEachTransitionGuard, + onErrorTransitionGuard, + popStateListener, + ] = enableViewTransition(this, options) + }) }, install(app: App) { @@ -1357,6 +1363,7 @@ export function createRouter( removeHistoryListener && removeHistoryListener() removeHistoryListener = null currentRoute.value = START_LOCATION_NORMALIZED + cleanupNativeViewTransition() started = false ready = false } From 591be5677b528ce0439dcf0e6f0801e9fa583e08 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 21:50:00 +0200 Subject: [PATCH 56/71] chore: change isChangingPage and legacy router registration --- packages/router/src/router.ts | 14 ++++++-------- packages/router/src/utils/routes.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 7f88a8f3b..52326e9f0 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1302,14 +1302,12 @@ export function createRouter( return } - this.isReady().then(() => { - ;[ - beforeResolveTransitionGuard, - afterEachTransitionGuard, - onErrorTransitionGuard, - popStateListener, - ] = enableViewTransition(this, options) - }) + ;[ + beforeResolveTransitionGuard, + afterEachTransitionGuard, + onErrorTransitionGuard, + popStateListener, + ] = enableViewTransition(this, options) }, install(app: App) { diff --git a/packages/router/src/utils/routes.ts b/packages/router/src/utils/routes.ts index e313c44f3..777a9fea2 100644 --- a/packages/router/src/utils/routes.ts +++ b/packages/router/src/utils/routes.ts @@ -1,5 +1,5 @@ import { RouteLocationNormalized } from '../typed-routes' -import { START_LOCATION } from '../index' +import { START_LOCATION_NORMALIZED } from '../location' // from Nuxt const ROUTE_KEY_PARENTHESES_RE = /(:\w+)\([^)]+\)/g @@ -24,7 +24,11 @@ export function isChangingPage( to: RouteLocationNormalized, from: RouteLocationNormalized ) { - if (to === from || from === START_LOCATION) { + if ( + to === START_LOCATION_NORMALIZED || + to === from || + from === START_LOCATION_NORMALIZED + ) { return false } From 9367a97a8ffdf18d9af9d0584163a54c986986e1 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 22:15:00 +0200 Subject: [PATCH 57/71] chore: update `beforeResolve` guards --- packages/router/src/navigation-api/index.ts | 7 +-- packages/router/src/router.ts | 54 ++++++++++++--------- packages/router/src/transition.ts | 10 ++-- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 546e8947c..449cca7eb 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -860,7 +860,7 @@ export function createNavigationApiRouter( } beforeResolveTransitionGuard = this.beforeResolve( - (to, from, next, info) => { + async (to, from, next, info) => { const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting if ( @@ -871,7 +871,6 @@ export function createNavigationApiRouter( window.matchMedia('(prefers-reduced-motion: reduce)').matches) || !isChangingPage(to, from) ) { - next(true) return } @@ -882,14 +881,12 @@ export function createNavigationApiRouter( const transition = document.startViewTransition(() => promise) - options.onStart?.(transition) + await options.onStart?.(transition) transition.finished .then(() => options.onFinished?.(transition)) .catch(() => options.onAborted?.(transition)) .finally(resetTransitionState) - next(true) - return promise } ) diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 52326e9f0..8774ac9bb 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1438,33 +1438,41 @@ function enableViewTransition(router: Router, options: RouterViewTransition) { window.addEventListener('popstate', popStateListener) - const beforeResolveTransitionGuard = router.beforeResolve((to, from) => { - const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting - if ( - hasUAVisualTransition || - transitionMode === false || - (transitionMode !== 'always' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches) || - !isChangingPage(to, from) - ) { - return - } + const beforeResolveTransitionGuard = router.beforeResolve( + async (to, from) => { + const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting + if ( + hasUAVisualTransition || + transitionMode === false || + (transitionMode !== 'always' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches) || + !isChangingPage(to, from) + ) { + return + } - const promise = new Promise((resolve, reject) => { - finishTransition = resolve - abortTransition = reject - }) + const promise = new Promise((resolve, reject) => { + finishTransition = resolve + abortTransition = reject + }) - const transition = document.startViewTransition(() => promise) + let changeRoute: () => void + const ready = new Promise(resolve => (changeRoute = resolve)) - options.onStart?.(transition) - transition.finished - .then(() => options.onFinished?.(transition)) - .catch(() => options.onAborted?.(transition)) - .finally(resetTransitionState) + const transition = document.startViewTransition(() => { + changeRoute() + return promise + }) - return promise - }) + await options.onStart?.(transition) + transition.finished + .then(() => options.onFinished?.(transition)) + .catch(() => options.onAborted?.(transition)) + .finally(resetTransitionState) + + return ready + } + ) const afterEachTransitionGuard = router.afterEach(() => { finishTransition?.() diff --git a/packages/router/src/transition.ts b/packages/router/src/transition.ts index 427cc8499..39bbaea5b 100644 --- a/packages/router/src/transition.ts +++ b/packages/router/src/transition.ts @@ -11,12 +11,16 @@ export function injectTransitionMode(): TransitionMode { return inject(transitionModeKey, 'auto') } +export type RouteViewTransitionHook = ( + transition: ViewTransition +) => void | Promise + export interface RouterViewTransition { defaultViewTransition?: boolean | 'always' /** Hook called right after the view transition starts */ - onStart?: (transition: ViewTransition) => void + onStart?: RouteViewTransitionHook /** Hook called when the view transition animation is finished */ - onFinished?: (transition: ViewTransition) => void + onFinished?: RouteViewTransitionHook /** Hook called if the transition is aborted */ - onAborted?: (transition: ViewTransition) => void + onAborted?: RouteViewTransitionHook } From ce59ba7167af13320df9b2c3b86f1a0b17fb892b Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 22:24:10 +0200 Subject: [PATCH 58/71] chore: update `beforeResolve` guard at nav. api router --- packages/router/src/navigation-api/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 449cca7eb..e966ff615 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -871,6 +871,7 @@ export function createNavigationApiRouter( window.matchMedia('(prefers-reduced-motion: reduce)').matches) || !isChangingPage(to, from) ) { + next(true) return } @@ -887,7 +888,8 @@ export function createNavigationApiRouter( .catch(() => options.onAborted?.(transition)) .finally(resetTransitionState) - return promise + // promise will be resolved + next(true) } ) From 8d3ce6a59a103c0b07802d92671751d4f3cb6090 Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 23:02:27 +0200 Subject: [PATCH 59/71] chore: change viewTransition option --- packages/router/src/client-router.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts index 74ae2a585..7d3c90dd4 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/client-router.ts @@ -1,6 +1,6 @@ import type { Router } from './router' import type { RouterApiOptions } from './navigation-api' -import type { RouterViewTransition, TransitionMode } from './transition' +import type { TransitionMode } from './transition' import { createNavigationApiRouter } from './navigation-api' import { isBrowser } from './utils' @@ -20,9 +20,11 @@ export interface ClientRouterOptions { options: RouterApiOptions } /** - * Enable native View Transitions. + * Enable Native View Transitions. + * + * @default false */ - viewTransition?: true | RouterViewTransition + viewTransition?: boolean } export function createClientRouter(options: ClientRouterOptions): Router { From b0cf9f8ec2fd6d3898b1f421ccbe03184576d53d Mon Sep 17 00:00:00 2001 From: userquin Date: Mon, 15 Sep 2025 23:19:07 +0200 Subject: [PATCH 60/71] chore: cleanup --- packages/router/src/navigation-api/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index e966ff615..524fbb56e 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -888,7 +888,6 @@ export function createNavigationApiRouter( .catch(() => options.onAborted?.(transition)) .finally(resetTransitionState) - // promise will be resolved next(true) } ) From f3ab6ccb56b4e89bd467cd1f0effda272dae5874 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 14:31:52 +0200 Subject: [PATCH 61/71] chore: add focus and scroll management options --- packages/router/src/history/common.ts | 32 ++- packages/router/src/navigation-api/index.ts | 252 ++++++++++++++++---- 2 files changed, 233 insertions(+), 51 deletions(-) diff --git a/packages/router/src/history/common.ts b/packages/router/src/history/common.ts index c9a5e7ce6..bf57e063f 100644 --- a/packages/router/src/history/common.ts +++ b/packages/router/src/history/common.ts @@ -44,6 +44,28 @@ export enum NavigationDirection { unknown = '', } +export interface NavigationApiEvent { + readonly navigationType: 'reload' | 'push' | 'replace' | 'traverse' + readonly canIntercept: boolean + readonly userInitiated: boolean + readonly hashChange: boolean + readonly hasUAVisualTransition: boolean + readonly destination: { + readonly url: string + readonly key: string | null + readonly id: string | null + readonly index: number + readonly sameDocument: boolean + getState(): unknown + } + readonly signal: AbortSignal + readonly formData: FormData | null + readonly downloadRequest: string | null + readonly info?: unknown + + scroll(): void +} + export interface NavigationInformation { type: NavigationType direction: NavigationDirection @@ -51,21 +73,21 @@ export interface NavigationInformation { /** * True if the navigation was triggered by the browser back button. * - * Note: available only with the new Navigation API. + * Note: available only with the new Navigation API Router. */ isBackBrowserButton?: boolean /** * True if the navigation was triggered by the browser forward button. * - * Note: available only with the new Navigation API. + * Note: available only with the new Navigation API Router. */ isForwardBrowserButton?: boolean /** - * AbortSignal that will be emitted when the navigation is aborted. + * The native Navigation API Event. * - * Note: available only with the new Navigation API. + * Note: available only with the new Navigation API Router. */ - signal?: AbortSignal + navigationApiEvent?: NavigationApiEvent } export interface NavigationCallback { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 524fbb56e..92f4067e2 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -62,9 +62,63 @@ import { RouteRecordNormalized } from '../matcher/types' import { TransitionMode, transitionModeKey } from '../transition' import { isChangingPage } from '../utils/routes' -export interface RouterApiOptions extends Omit { +/** + * Options for {@link createNavigationApiRouter}. + * + * This function creates an "opinionated" router that provides smart, modern + * defaults for features like scroll restoration, focus management, and View + * Transitions, aiming to deliver a best-in-class, accessible user experience + * out of the box with zero configuration. + * + * It differs from the legacy `createRouter`, which acts more like a library by + * providing the tools (`scrollBehavior`) but leaving the implementation of these + * features to the developer. + * + * While this router provides smart defaults, it also allows for full customization + * by providing your own `scroll behavior` function or fine-tuning focus management, + * giving you the best of both worlds. + */ +export interface RouterApiOptions + extends Omit { base?: string location: string + /** + * Focus management. + * + * This can be overridden per route by passing `focusManagement` in the route meta, will take precedence over this option. + * + * If `undefined`, the router will not manage focus: will use the [default behavior](https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/intercept#focusreset). + * + * If `true`, the router will focus the first element in the dom using `document.querySelector('[autofocus], h1, main, body')`. + * + * If `false`, the router and the browser will not manage the focus, the consumer should manage the focus in the router guards or target page components. + * + * If a `string`, the router will use `document.querySelector(focusManagement)` to find the element to be focused, if the element is not found, then it will try to find the element using the selector when the option is `true`. + * + * @default undefined + */ + focusManagement?: boolean | string + /** + * Controls the scroll management strategy, allowing you to opt-into the + * manual `vue-router` `scrollBehavior` system for fine-grained control + * via [NavigateEvent.scroll](https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/scroll) + * in your route guards. + * + * This can be overridden per-route by defining a `scrollManagement` in the + * route's `meta` field. This takes precedence over this option. + * + * The default behavior is to leverage the browser's native scroll handling: + * - `undefined` (default) or `after-transition`: The router leverages the + * browser's built-in, performant scroll handling (`scroll: 'after-transition'`). + * This provides an excellent default experience that respects modern CSS + * properties like `scroll-padding-top` and restores scroll position automatically + * on back/forward navigations. + * - `manual`: Disables the browser's native scroll management (`scroll: 'manual'`) + * and enables using scroll via native [NavigateEvent.scroll](https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/scroll) in your route guards. + * + * @default undefined + */ + scrollManagement?: 'after-transition' | 'manual' } export function createNavigationApiRouter( @@ -89,6 +143,7 @@ export function createNavigationApiRouter( let isRevertingNavigation = false let pendingLocation: RouteLocation | undefined + let focusTimeoutId: ReturnType | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = START_LOCATION_NORMALIZED @@ -237,18 +292,63 @@ export function createNavigationApiRouter( await runGuardQueue(guards) } + interface FinalizeNavigationOptions { + failure?: NavigationFailure + focus?: { + focusReset: 'after-transition' | 'manual' + selector?: string + } + } + function finalizeNavigation( to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - failure?: NavigationFailure + options: FinalizeNavigationOptions = {} ) { pendingLocation = undefined + const { failure, focus } = options if (!failure) { lastSuccessfulLocation = to } currentRoute.value = to as RouteLocationNormalizedLoaded markAsReady() afterGuards.list().forEach(guard => guard(to, from, failure)) + + if (!failure && focus) { + const { focusReset, selector } = focus + // We only need to handle focus here, to prevent scrolling. + // When focusManagement is false, selector is undefined. + // So we can have the following cases: + // - focusReset: after-transition -> default browser behavior: no action required here + // - focusReset: manual, selector undefined -> no action required here + // - focusReset: manual, selector with value -> prevent scrolling when focusing the target selector element + // We don't need to handle scroll here, the browser or user guards or components lifecycle hooks will handle it + if (focusReset === 'manual' && selector) { + clearTimeout(focusTimeoutId) + requestAnimationFrame(() => { + focusTimeoutId = setTimeout(() => { + const target = document.querySelector(selector) + if (!target) return + target.focus({ preventScroll: true }) + if (document.activeElement === target) return + // element has tabindex already, likely not focusable + // because of some other reason, bail out + if (target.hasAttribute('tabindex')) return + const restoreTabindex = () => { + target.removeAttribute('tabindex') + target.removeEventListener('blur', restoreTabindex) + } + // temporarily make the target element focusable + target.setAttribute('tabindex', '-1') + target.addEventListener('blur', restoreTabindex) + // try to focus again + target.focus({ preventScroll: true }) + // remove tabindex and event listener if focus still not worked + if (document.activeElement !== target) restoreTabindex() + }, 0) + }) + } + } } function markAsReady(err?: any): void { @@ -411,7 +511,7 @@ export function createNavigationApiRouter( from, } ) - finalizeNavigation(from, from, failure) + finalizeNavigation(from, from, { failure }) return failure } @@ -627,54 +727,104 @@ export function createNavigationApiRouter( ) } + function prepareTargetLocation( + event: NavigateEvent + ): RouteLocationNormalized { + if (!pendingLocation) { + const destination = new URL(event.destination.url) + const pathWithSearchAndHash = + destination.pathname + destination.search + destination.hash + return resolve(pathWithSearchAndHash) as RouteLocationNormalized + } + + return pendingLocation as RouteLocationNormalized + } + + function prepareFocusReset(to: RouteLocationNormalized) { + let focusReset: 'after-transition' | 'manual' = 'after-transition' + let selector: string | undefined + + const focusManagement = to.meta.focusManagement ?? options.focusManagement + if (focusManagement === false) { + focusReset = 'manual' + } + if (focusManagement === true) { + focusReset = 'manual' + selector = '[autofocus],h1,main,body' + } else if (typeof focusManagement === 'string') { + focusReset = 'manual' + selector = focusManagement || '[autofocus],h1,main,body' + } + + return [focusReset, selector] as const + } + + function prepareScrollManagement( + to: RouteLocationNormalized + ): 'after-transition' | 'manual' { + let scrollManagement: 'after-transition' | 'manual' = 'after-transition' + const scrollMeta = to.meta.scrollManagement ?? options.scrollManagement + if (scrollMeta === 'manual') { + scrollManagement = 'manual' + } + + return scrollManagement + } + async function handleNavigate(event: NavigateEvent) { - if (!event.canIntercept) return + clearTimeout(focusTimeoutId) + + if (!event.canIntercept) { + return + } + + const targetLocation = prepareTargetLocation(event) + const from = currentRoute.value + + // the calculation should be here, if running this logic inside the intercept handler + // the back and forward buttons cannot be detected properly since the currentEntry + // is already updated when the handler is executed. + let navigationInfo: NavigationInformation | undefined + if (event.navigationType === 'traverse') { + const fromIndex = window.navigation.currentEntry?.index ?? -1 + const toIndex = event.destination.index + const delta = fromIndex === -1 ? 0 : toIndex - fromIndex + + navigationInfo = { + type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. + direction: + delta > 0 ? NavigationDirection.forward : NavigationDirection.back, + delta, + isBackBrowserButton: delta < 0, + isForwardBrowserButton: delta > 0, + navigationApiEvent: event, + } + } else if ( + event.navigationType === 'push' || + event.navigationType === 'replace' + ) { + navigationInfo = { + type: + event.navigationType === 'push' + ? NavigationType.push + : NavigationType.pop, + direction: NavigationDirection.unknown, // No specific direction for push/replace. + delta: event.navigationType === 'push' ? 1 : 0, + navigationApiEvent: event, + } + } + + const [focusReset, focusSelector] = prepareFocusReset(targetLocation) event.intercept({ + focusReset, + scroll: prepareScrollManagement(targetLocation), async handler() { if (!pendingLocation) { - const destination = new URL(event.destination.url) - const pathWithSearchAndHash = - destination.pathname + destination.search + destination.hash - pendingLocation = resolve( - pathWithSearchAndHash - ) as RouteLocationNormalized + pendingLocation = targetLocation } const to = pendingLocation as RouteLocationNormalized - const from = currentRoute.value - - let navigationInfo: NavigationInformation | undefined - if (event.navigationType === 'traverse') { - const fromIndex = window.navigation.currentEntry?.index ?? -1 - const toIndex = event.destination.index - const delta = fromIndex === -1 ? 0 : toIndex - fromIndex - - navigationInfo = { - type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. - direction: - delta > 0 - ? NavigationDirection.forward - : NavigationDirection.back, - delta, - isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0, - signal: event.signal, - } - } else if ( - event.navigationType === 'push' || - event.navigationType === 'replace' - ) { - navigationInfo = { - type: - event.navigationType === 'push' - ? NavigationType.push - : NavigationType.pop, - direction: NavigationDirection.unknown, // No specific direction for push/replace. - delta: event.navigationType === 'push' ? 1 : 0, - signal: event.signal, - } - } if ( from !== START_LOCATION_NORMALIZED && @@ -686,7 +836,12 @@ export function createNavigationApiRouter( try { await resolveNavigationGuards(to, from, navigationInfo) - finalizeNavigation(to, from) + finalizeNavigation(to, from, { + focus: { + focusReset, + selector: focusSelector, + }, + }) } catch (error) { const failure = error as NavigationFailure @@ -713,6 +868,7 @@ export function createNavigationApiRouter( async function handleCurrentEntryChange( event: NavigationCurrentEntryChangeEvent ) { + clearTimeout(focusTimeoutId) if (isRevertingNavigation) { isRevertingNavigation = false return @@ -740,12 +896,16 @@ export function createNavigationApiRouter( isForwardBrowserButton: delta > 0, } + const [focusReset, focusSelector] = prepareFocusReset(to) + pendingLocation = to try { // then browser has been done the navigation, we just run the guards await resolveNavigationGuards(to, from, navigationInfo) - finalizeNavigation(to, from) + finalizeNavigation(to, from, { + focus: { focusReset, selector: focusSelector }, + }) } catch (error) { const failure = error as NavigationFailure @@ -753,7 +913,7 @@ export function createNavigationApiRouter( go(event.from.index - window.navigation.currentEntry!.index) // we end up at from to keep consistency - finalizeNavigation(from, to, failure) + finalizeNavigation(from, to, { failure }) if (isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)) { navigate((failure as NavigationRedirectError).to, { replace: true }) From 98ee9f4f772a2301fb3684f7a4fd59dd650623ab Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 16:43:20 +0200 Subject: [PATCH 62/71] chore: remove back and forward buttons check from view transition logic --- packages/router/src/navigation-api/index.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 92f4067e2..ed312d307 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1020,12 +1020,10 @@ export function createNavigationApiRouter( } beforeResolveTransitionGuard = this.beforeResolve( - async (to, from, next, info) => { + async (to, from, next) => { const transitionMode = to.meta.viewTransition ?? defaultTransitionSetting if ( - info?.isBackBrowserButton || - info?.isForwardBrowserButton || transitionMode === false || (transitionMode !== 'always' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) || @@ -1052,7 +1050,17 @@ export function createNavigationApiRouter( } ) - afterEachTransitionGuard = this.afterEach(() => { + afterEachTransitionGuard = this.afterEach((to, from) => { + const transitionMode = + to.meta.viewTransition ?? defaultTransitionSetting + if ( + transitionMode === false || + (transitionMode !== 'always' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches) || + !isChangingPage(to, from) + ) { + return + } finishTransition?.() }) From 0674d042f04165e92c6d5928c4737858f07df6fb Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 16:56:32 +0200 Subject: [PATCH 63/71] chore: simplify view transition hooks --- packages/router/src/navigation-api/index.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index ed312d307..e4b06d043 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1050,17 +1050,7 @@ export function createNavigationApiRouter( } ) - afterEachTransitionGuard = this.afterEach((to, from) => { - const transitionMode = - to.meta.viewTransition ?? defaultTransitionSetting - if ( - transitionMode === false || - (transitionMode !== 'always' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches) || - !isChangingPage(to, from) - ) { - return - } + afterEachTransitionGuard = this.afterEach(() => { finishTransition?.() }) From a5a2a882ff4335146831b47755d9615c7d026e16 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 17:56:22 +0200 Subject: [PATCH 64/71] chore: remove transition error race when using back or forward browser buttons --- packages/router/src/navigation-api/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index e4b06d043..307964d90 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -774,7 +774,7 @@ export function createNavigationApiRouter( async function handleNavigate(event: NavigateEvent) { clearTimeout(focusTimeoutId) - if (!event.canIntercept) { + if (!event.canIntercept || event.navigationType === 'traverse') { return } @@ -785,7 +785,7 @@ export function createNavigationApiRouter( // the back and forward buttons cannot be detected properly since the currentEntry // is already updated when the handler is executed. let navigationInfo: NavigationInformation | undefined - if (event.navigationType === 'traverse') { + /*if (event.navigationType === 'traverse') { const fromIndex = window.navigation.currentEntry?.index ?? -1 const toIndex = event.destination.index const delta = fromIndex === -1 ? 0 : toIndex - fromIndex @@ -799,7 +799,7 @@ export function createNavigationApiRouter( isForwardBrowserButton: delta > 0, navigationApiEvent: event, } - } else if ( + } else */ if ( event.navigationType === 'push' || event.navigationType === 'replace' ) { From b74d264571cc5ea7140846c89895bf7189eee390 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 18:05:37 +0200 Subject: [PATCH 65/71] chore: cleanup handleNavigate --- packages/router/src/navigation-api/index.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 307964d90..460d1b730 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -774,6 +774,7 @@ export function createNavigationApiRouter( async function handleNavigate(event: NavigateEvent) { clearTimeout(focusTimeoutId) + // won't handle here 'traverse' navigations to avoid race conditions, see handleCurrentEntryChange if (!event.canIntercept || event.navigationType === 'traverse') { return } @@ -785,24 +786,7 @@ export function createNavigationApiRouter( // the back and forward buttons cannot be detected properly since the currentEntry // is already updated when the handler is executed. let navigationInfo: NavigationInformation | undefined - /*if (event.navigationType === 'traverse') { - const fromIndex = window.navigation.currentEntry?.index ?? -1 - const toIndex = event.destination.index - const delta = fromIndex === -1 ? 0 : toIndex - fromIndex - - navigationInfo = { - type: NavigationType.pop, // 'traverse' maps to 'pop' in vue-router's terminology. - direction: - delta > 0 ? NavigationDirection.forward : NavigationDirection.back, - delta, - isBackBrowserButton: delta < 0, - isForwardBrowserButton: delta > 0, - navigationApiEvent: event, - } - } else */ if ( - event.navigationType === 'push' || - event.navigationType === 'replace' - ) { + if (event.navigationType === 'push' || event.navigationType === 'replace') { navigationInfo = { type: event.navigationType === 'push' From e968961eea5c18b682629b2782f5fdf399b5a49d Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 18:43:17 +0200 Subject: [PATCH 66/71] chore: fix Windows dts build --- .../router/add-dts-module-augmentation.mjs | 24 +++++++++++++++++++ packages/router/package.json | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/router/add-dts-module-augmentation.mjs diff --git a/packages/router/add-dts-module-augmentation.mjs b/packages/router/add-dts-module-augmentation.mjs new file mode 100644 index 000000000..84d738583 --- /dev/null +++ b/packages/router/add-dts-module-augmentation.mjs @@ -0,0 +1,24 @@ +import * as fs from 'node:fs/promises' + +async function patchVueRouterDts() { + const content = await fs.readFile('./src/globalExtensions.ts', { + encoding: 'utf-8', + }) + const moduleAugmentationIdx = content.indexOf('/**') + if (moduleAugmentationIdx === -1) { + throw new Error( + 'Cannot find module augmentation in globalExtensions.ts, first /** comment is expected to start module augmentation' + ) + } + const targetContent = await fs.readFile('./dist/vue-router.d.ts', { + encoding: 'utf-8', + }) + await fs.writeFile( + './dist/vue-router.d.ts', + `${targetContent} +${content.slice(moduleAugmentationIdx)}`, + { encoding: 'utf8' } + ) +} + +patchVueRouterDts() diff --git a/packages/router/package.json b/packages/router/package.json index 19ce2a3bd..9360ac21d 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -91,7 +91,7 @@ "dev": "vitest --ui", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 1", "build": "rimraf dist && rollup -c rollup.config.mjs", - "build:dts": "api-extractor run --local --verbose && tail -n +10 src/globalExtensions.ts >> dist/vue-router.d.ts", + "build:dts": "api-extractor run --local --verbose && node ./add-dts-module-augmentation.mjs", "build:playground": "vue-tsc --noEmit && vite build --config playground/vite.config.ts", "build:e2e": "vue-tsc --noEmit && vite build --config e2e/vite.config.mjs", "build:size": "pnpm run build && rollup -c size-checks/rollup.config.mjs", From d4fa24986c9d957bda18de3e753d0074b52f8173 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 19:11:37 +0200 Subject: [PATCH 67/71] chore: add `name` to router to allow adding helpers for legacy router --- packages/router/src/navigation-api/index.ts | 1 + packages/router/src/router.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 460d1b730..1caebd584 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -952,6 +952,7 @@ export function createNavigationApiRouter( } const router: Router = { + name: 'navigation-api', currentRoute, listening: true, diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 8774ac9bb..f3a9e189f 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -192,6 +192,7 @@ export interface RouterOptions extends PathParserOptions { * Router instance. */ export interface Router { + readonly name: 'legacy' | 'navigation-api' /** * @internal */ @@ -1264,6 +1265,7 @@ export function createRouter( } const router: Router = { + name: 'legacy', currentRoute, listening: true, From 8a35345f08cfaa103c631d0c9d980d6b72d6806b Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 20:53:35 +0200 Subject: [PATCH 68/71] chore: add focus management "polyfill" to legacy router --- packages/router/src/client-router.ts | 4 +- packages/router/src/focus.ts | 104 ++++++++++++++++++++ packages/router/src/navigation-api/index.ts | 77 +++------------ packages/router/src/router.ts | 23 +++++ 4 files changed, 143 insertions(+), 65 deletions(-) create mode 100644 packages/router/src/focus.ts diff --git a/packages/router/src/client-router.ts b/packages/router/src/client-router.ts index 7d3c90dd4..b7a4569bc 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/client-router.ts @@ -7,7 +7,7 @@ import { isBrowser } from './utils' export interface ClientRouterOptions { /** * Factory function that creates a legacy router instance. - * Typically: () => createRouter({ history: createWebHistory(), routes }) + * Typically: () => createRouter({@ history: createWebHistory(), routes }) */ legacy: { factory: (transitionMode: TransitionMode) => Router @@ -22,7 +22,7 @@ export interface ClientRouterOptions { /** * Enable Native View Transitions. * - * @default false + * @default undefined */ viewTransition?: boolean } diff --git a/packages/router/src/focus.ts b/packages/router/src/focus.ts new file mode 100644 index 000000000..0ef49616a --- /dev/null +++ b/packages/router/src/focus.ts @@ -0,0 +1,104 @@ +import type { RouteLocationNormalized } from './typed-routes' +import { Router, RouterOptions } from './router' + +export function enableFocusManagement(router: Router) { + // navigation-api router will handle this for us + if (router.name !== 'legacy') { + return + } + + const { handleFocus, clearFocusTimeout } = createFocusManagementHandler() + + const unregisterBeforeEach = router.beforeEach(() => { + clearFocusTimeout() + }) + + const unregister = router.afterEach(async (to, from) => { + const focusManagement = + to.meta.focusManagement ?? router.options.focusManagement + + // user wants manual focus + if (focusManagement === false) return + + let selector = '[autofocus], body' + + if (focusManagement === true) { + selector = '[autofocus],h1,main,body' + } else if ( + typeof focusManagement === 'string' && + focusManagement.length > 0 + ) { + selector = focusManagement + } + + handleFocus(selector) + }) + + return () => { + clearFocusTimeout() + unregisterBeforeEach() + unregister() + } +} + +export function prepareFocusReset( + to: RouteLocationNormalized, + routerFocusManagement?: RouterOptions['focusManagement'] +) { + let focusReset: 'after-transition' | 'manual' = 'after-transition' + let selector: string | undefined + + const focusManagement = to.meta.focusManagement ?? routerFocusManagement + if (focusManagement === false) { + focusReset = 'manual' + } + if (focusManagement === true) { + focusReset = 'manual' + selector = '[autofocus],h1,main,body' + } else if (typeof focusManagement === 'string') { + focusReset = 'manual' + selector = focusManagement || '[autofocus],h1,main,body' + } + + return [focusReset, selector] as const +} + +export function createFocusManagementHandler() { + let timeoutId: ReturnType | undefined + return { + handleFocus: (selector: string) => { + clearTimeout(timeoutId) + requestAnimationFrame(() => { + timeoutId = handleFocusManagement(selector) + }) + }, + clearFocusTimeout: () => { + clearTimeout(timeoutId) + }, + } +} + +function handleFocusManagement( + selector: string +): ReturnType { + return setTimeout(() => { + const target = document.querySelector(selector) + if (!target) return + target.focus({ preventScroll: true }) + if (document.activeElement === target) return + // element has tabindex already, likely not focusable + // because of some other reason, bail out + if (target.hasAttribute('tabindex')) return + const restoreTabindex = () => { + target.removeAttribute('tabindex') + target.removeEventListener('blur', restoreTabindex) + } + // temporarily make the target element focusable + target.setAttribute('tabindex', '-1') + target.addEventListener('blur', restoreTabindex) + // try to focus again + target.focus({ preventScroll: true }) + // remove tabindex and event listener if focus still not worked + if (document.activeElement !== target) restoreTabindex() + }, 0) +} diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 1caebd584..119e35d36 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -61,6 +61,7 @@ import { import { RouteRecordNormalized } from '../matcher/types' import { TransitionMode, transitionModeKey } from '../transition' import { isChangingPage } from '../utils/routes' +import { createFocusManagementHandler, prepareFocusReset } from '../focus' /** * Options for {@link createNavigationApiRouter}. @@ -82,22 +83,6 @@ export interface RouterApiOptions extends Omit { base?: string location: string - /** - * Focus management. - * - * This can be overridden per route by passing `focusManagement` in the route meta, will take precedence over this option. - * - * If `undefined`, the router will not manage focus: will use the [default behavior](https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/intercept#focusreset). - * - * If `true`, the router will focus the first element in the dom using `document.querySelector('[autofocus], h1, main, body')`. - * - * If `false`, the router and the browser will not manage the focus, the consumer should manage the focus in the router guards or target page components. - * - * If a `string`, the router will use `document.querySelector(focusManagement)` to find the element to be focused, if the element is not found, then it will try to find the element using the selector when the option is `true`. - * - * @default undefined - */ - focusManagement?: boolean | string /** * Controls the scroll management strategy, allowing you to opt-into the * manual `vue-router` `scrollBehavior` system for fine-grained control @@ -143,7 +128,6 @@ export function createNavigationApiRouter( let isRevertingNavigation = false let pendingLocation: RouteLocation | undefined - let focusTimeoutId: ReturnType | undefined let lastSuccessfulLocation: RouteLocationNormalizedLoaded = START_LOCATION_NORMALIZED @@ -300,6 +284,8 @@ export function createNavigationApiRouter( } } + const { handleFocus, clearFocusTimeout } = createFocusManagementHandler() + function finalizeNavigation( to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, @@ -324,29 +310,7 @@ export function createNavigationApiRouter( // - focusReset: manual, selector with value -> prevent scrolling when focusing the target selector element // We don't need to handle scroll here, the browser or user guards or components lifecycle hooks will handle it if (focusReset === 'manual' && selector) { - clearTimeout(focusTimeoutId) - requestAnimationFrame(() => { - focusTimeoutId = setTimeout(() => { - const target = document.querySelector(selector) - if (!target) return - target.focus({ preventScroll: true }) - if (document.activeElement === target) return - // element has tabindex already, likely not focusable - // because of some other reason, bail out - if (target.hasAttribute('tabindex')) return - const restoreTabindex = () => { - target.removeAttribute('tabindex') - target.removeEventListener('blur', restoreTabindex) - } - // temporarily make the target element focusable - target.setAttribute('tabindex', '-1') - target.addEventListener('blur', restoreTabindex) - // try to focus again - target.focus({ preventScroll: true }) - // remove tabindex and event listener if focus still not worked - if (document.activeElement !== target) restoreTabindex() - }, 0) - }) + handleFocus(selector) } } } @@ -740,25 +704,6 @@ export function createNavigationApiRouter( return pendingLocation as RouteLocationNormalized } - function prepareFocusReset(to: RouteLocationNormalized) { - let focusReset: 'after-transition' | 'manual' = 'after-transition' - let selector: string | undefined - - const focusManagement = to.meta.focusManagement ?? options.focusManagement - if (focusManagement === false) { - focusReset = 'manual' - } - if (focusManagement === true) { - focusReset = 'manual' - selector = '[autofocus],h1,main,body' - } else if (typeof focusManagement === 'string') { - focusReset = 'manual' - selector = focusManagement || '[autofocus],h1,main,body' - } - - return [focusReset, selector] as const - } - function prepareScrollManagement( to: RouteLocationNormalized ): 'after-transition' | 'manual' { @@ -772,7 +717,7 @@ export function createNavigationApiRouter( } async function handleNavigate(event: NavigateEvent) { - clearTimeout(focusTimeoutId) + clearFocusTimeout() // won't handle here 'traverse' navigations to avoid race conditions, see handleCurrentEntryChange if (!event.canIntercept || event.navigationType === 'traverse') { @@ -798,7 +743,10 @@ export function createNavigationApiRouter( } } - const [focusReset, focusSelector] = prepareFocusReset(targetLocation) + const [focusReset, focusSelector] = prepareFocusReset( + targetLocation, + options.focusManagement + ) event.intercept({ focusReset, @@ -852,7 +800,7 @@ export function createNavigationApiRouter( async function handleCurrentEntryChange( event: NavigationCurrentEntryChangeEvent ) { - clearTimeout(focusTimeoutId) + clearFocusTimeout() if (isRevertingNavigation) { isRevertingNavigation = false return @@ -880,7 +828,10 @@ export function createNavigationApiRouter( isForwardBrowserButton: delta > 0, } - const [focusReset, focusSelector] = prepareFocusReset(to) + const [focusReset, focusSelector] = prepareFocusReset( + to, + options.focusManagement + ) pendingLocation = to diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index f3a9e189f..fd8bd5bda 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -74,6 +74,7 @@ import { transitionModeKey, } from './transition' import { isChangingPage } from './utils/routes' +import { enableFocusManagement } from './focus' /** * Internal type to define an ErrorHandler @@ -186,6 +187,22 @@ export interface RouterOptions extends PathParserOptions { * `router-link-inactive` will be applied. */ // linkInactiveClass?: string + /** + * Focus management. + * + * This can be overridden per route by passing `focusManagement` in the route meta, will take precedence over this option. + * + * If `undefined`, the router will not manage focus: will use the [default behavior](https://developer.mozilla.org/en-US/docs/Web/API/NavigateEvent/intercept#focusreset). + * + * If `true`, the router will focus the first element in the dom using `document.querySelector('[autofocus], h1, main, body')`. + * + * If `false`, the router and the browser will not manage the focus, the consumer should manage the focus in the router guards or target page components. + * + * If a `string`, the router will use `document.querySelector(focusManagement)` to find the element to be focused, if the element is not found, then it will try to find the element using the selector when the option is `true`. + * + * @default undefined + */ + focusManagement?: boolean | string } /** @@ -1322,6 +1339,8 @@ export function createRouter( get: () => unref(currentRoute), }) + let cleanupFocusManagement: (() => void) | undefined + // 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 @@ -1332,6 +1351,9 @@ export function createRouter( !started && currentRoute.value === START_LOCATION_NORMALIZED ) { + if ('focusManagement' in options) { + cleanupFocusManagement = enableFocusManagement(router) + } // see above started = true push(routerHistory.location).catch(err => { @@ -1363,6 +1385,7 @@ export function createRouter( removeHistoryListener && removeHistoryListener() removeHistoryListener = null currentRoute.value = START_LOCATION_NORMALIZED + cleanupFocusManagement?.() cleanupNativeViewTransition() started = false ready = false From ad1e5d3f01a4873512ea02b7cbe0753062349d1e Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 22:21:18 +0200 Subject: [PATCH 69/71] chore: add microtask before handling focus --- packages/router/src/focus.ts | 9 +++++++-- packages/router/src/navigation-api/index.ts | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/router/src/focus.ts b/packages/router/src/focus.ts index 0ef49616a..6a722d884 100644 --- a/packages/router/src/focus.ts +++ b/packages/router/src/focus.ts @@ -1,5 +1,6 @@ import type { RouteLocationNormalized } from './typed-routes' import { Router, RouterOptions } from './router' +import { nextTick } from 'vue' export function enableFocusManagement(router: Router) { // navigation-api router will handle this for us @@ -13,7 +14,7 @@ export function enableFocusManagement(router: Router) { clearFocusTimeout() }) - const unregister = router.afterEach(async (to, from) => { + const unregister = router.afterEach(async to => { const focusManagement = to.meta.focusManagement ?? router.options.focusManagement @@ -31,6 +32,9 @@ export function enableFocusManagement(router: Router) { selector = focusManagement } + // ensure DOM is updated, enqueuing a microtask before handling focus + await nextTick() + handleFocus(selector) }) @@ -65,6 +69,7 @@ export function prepareFocusReset( export function createFocusManagementHandler() { let timeoutId: ReturnType | undefined + return { handleFocus: (selector: string) => { clearTimeout(timeoutId) @@ -100,5 +105,5 @@ function handleFocusManagement( target.focus({ preventScroll: true }) // remove tabindex and event listener if focus still not worked if (document.activeElement !== target) restoreTabindex() - }, 0) + }, 150) // screen readers may need more time to react } diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 119e35d36..50608b737 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -1,4 +1,4 @@ -import type { App } from 'vue' +import { App, nextTick } from 'vue' import { shallowReactive, shallowRef, unref } from 'vue' import { parseURL, @@ -310,7 +310,10 @@ export function createNavigationApiRouter( // - focusReset: manual, selector with value -> prevent scrolling when focusing the target selector element // We don't need to handle scroll here, the browser or user guards or components lifecycle hooks will handle it if (focusReset === 'manual' && selector) { - handleFocus(selector) + // ensure DOM is updated, enqueuing a microtask before handling focus + nextTick(() => { + handleFocus(selector) + }) } } } From d2825257642a3e66ab8c48432936deb33128df43 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 22:54:02 +0200 Subject: [PATCH 70/71] chore: add enable automatic scroll management to legacy router --- packages/router/src/focus.ts | 2 +- packages/router/src/navigation-api/index.ts | 5 ++- packages/router/src/router.ts | 43 ++++++++++++++++++--- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/router/src/focus.ts b/packages/router/src/focus.ts index 6a722d884..f298a3d2f 100644 --- a/packages/router/src/focus.ts +++ b/packages/router/src/focus.ts @@ -1,5 +1,5 @@ import type { RouteLocationNormalized } from './typed-routes' -import { Router, RouterOptions } from './router' +import type { Router, RouterOptions } from './router' import { nextTick } from 'vue' export function enableFocusManagement(router: Router) { diff --git a/packages/router/src/navigation-api/index.ts b/packages/router/src/navigation-api/index.ts index 50608b737..3aa9d9bcf 100644 --- a/packages/router/src/navigation-api/index.ts +++ b/packages/router/src/navigation-api/index.ts @@ -80,7 +80,10 @@ import { createFocusManagementHandler, prepareFocusReset } from '../focus' * giving you the best of both worlds. */ export interface RouterApiOptions - extends Omit { + extends Omit< + RouterOptions, + 'history' | 'scrollBehavior' | 'enableScrollManagement' + > { base?: string location: string /** diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index fd8bd5bda..7663f817e 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -20,7 +20,12 @@ import type { RouteLocationAsString, RouteRecordNameGeneric, } from './typed-routes' -import { RouterHistory, HistoryState, NavigationType } from './history/common' +import { + RouterHistory, + HistoryState, + NavigationType, + NavigationInformation, +} from './history/common' import { ScrollPosition, getSavedScrollPosition, @@ -108,7 +113,8 @@ export interface RouterScrollBehavior { ( to: RouteLocationNormalized, from: RouteLocationNormalizedLoaded, - savedPosition: _ScrollPositionNormalized | null + savedPosition: _ScrollPositionNormalized | null, + info?: NavigationInformation ): Awaitable } @@ -203,6 +209,14 @@ export interface RouterOptions extends PathParserOptions { * @default undefined */ focusManagement?: boolean | string + /** + * Enable automatic scroll restoration when navigating the history. + * + * Enabling this option, will register a custom `scrollBehavior` if none is provided. + * + * `focusManagement` and this option are just used to enable some sort of "polyfills" for browsers that do not support the Navigation API. + */ + enableScrollManagement?: true } /** @@ -1066,6 +1080,7 @@ export function createRouter( return } + toLocation.meta.__info = info pendingLocation = toLocation const from = currentRoute.value @@ -1238,6 +1253,23 @@ export function createRouter( return err } + const { enableScrollManagement, scrollBehavior } = options + + const useScrollBehavior: RouterScrollBehavior | undefined = + scrollBehavior ?? + (enableScrollManagement + ? async (to, from, savedPosition, info) => { + await nextTick() + if (info?.type === 'pop' && savedPosition) { + return scrollToPosition(savedPosition) + } + if (to.hash) { + return scrollToPosition({ el: to.hash, behavior: 'smooth' }) + } + return scrollToPosition({ top: 0, left: 0 }) + } + : undefined) + // Scroll behavior function handleScroll( to: RouteLocationNormalizedLoaded, @@ -1246,8 +1278,9 @@ export function createRouter( isFirstNavigation: boolean ): // the return is not meant to be used Promise { - const { scrollBehavior } = options - if (!isBrowser || !scrollBehavior) return Promise.resolve() + const info = to.meta.__info as NavigationInformation | undefined + delete to.meta.__info + if (!isBrowser || !useScrollBehavior) return Promise.resolve() const scrollPosition: _ScrollPositionNormalized | null = (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) || @@ -1257,7 +1290,7 @@ export function createRouter( null return nextTick() - .then(() => scrollBehavior(to, from, scrollPosition)) + .then(() => useScrollBehavior(to, from, scrollPosition, info)) .then(position => position && scrollToPosition(position)) .catch(err => triggerError(err, to, from)) } From 11632e0fabb7085619bc6551df213f93eac7d6e8 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 17 Sep 2025 23:35:43 +0200 Subject: [PATCH 71/71] chore: rename client router to modern router --- packages/router/src/index.ts | 4 ++-- .../router/src/{client-router.ts => modern-router-factory.ts} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename packages/router/src/{client-router.ts => modern-router-factory.ts} (92%) diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 0f081a692..6feb91279 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -142,9 +142,9 @@ export { createNavigationApiRouter } from './navigation-api' export type { Router, RouterOptions, RouterScrollBehavior } from './router' export type { RouterApiOptions } from './navigation-api' export type { TransitionMode, RouterViewTransition } from './transition' -export type { ClientRouterOptions } from './client-router' +export type { ModernRouterOptions } from './modern-router-factory' export { injectTransitionMode, transitionModeKey } from './transition' -export { createClientRouter } from './client-router' +export { createModernRouter } from './modern-router-factory' export { NavigationFailureType, isNavigationFailure } from './errors' export type { diff --git a/packages/router/src/client-router.ts b/packages/router/src/modern-router-factory.ts similarity index 92% rename from packages/router/src/client-router.ts rename to packages/router/src/modern-router-factory.ts index b7a4569bc..7240415d8 100644 --- a/packages/router/src/client-router.ts +++ b/packages/router/src/modern-router-factory.ts @@ -4,7 +4,7 @@ import type { TransitionMode } from './transition' import { createNavigationApiRouter } from './navigation-api' import { isBrowser } from './utils' -export interface ClientRouterOptions { +export interface ModernRouterOptions { /** * Factory function that creates a legacy router instance. * Typically: () => createRouter({@ history: createWebHistory(), routes }) @@ -27,7 +27,7 @@ export interface ClientRouterOptions { viewTransition?: boolean } -export function createClientRouter(options: ClientRouterOptions): Router { +export function createModernRouter(options: ModernRouterOptions): Router { let transitionMode: TransitionMode = 'auto' if (