import {
- normalizeLocation,
+ RouteLocationNormalized,
+ RouteRecord,
+ RouteLocation,
+ NavigationGuard,
+ ListenerRemover,
+ PostNavigationGuard,
+ START_LOCATION_NORMALIZED,
+ MatcherLocation,
+ RouteQueryAndHash,
+ Lazy,
+ TODO,
+} from './types'
+import {
RouterHistory,
+ normalizeLocation,
stringifyURL,
normalizeQuery,
HistoryLocationNormalized,
START,
} from './history/common'
-import { createRouterMatcher } from './matcher'
-import {
- RouteLocation,
- RouteRecord,
- START_LOCATION_NORMALIZED,
- RouteLocationNormalized,
- ListenerRemover,
- NavigationGuard,
- TODO,
- PostNavigationGuard,
- Lazy,
- MatcherLocation,
- RouteQueryAndHash,
-} from './types/index'
import {
ScrollToPosition,
ScrollPosition,
scrollToPosition,
} from './utils/scroll'
-
-import { guardToPromiseFn, extractComponentsGuards } from './utils'
+import { createRouterMatcher } from './matcher'
import {
+ NavigationCancelled,
NavigationGuardRedirect,
NavigationAborted,
- NavigationCancelled,
} from './errors'
+import { extractComponentsGuards, guardToPromiseFn } from './utils'
+import Vue from 'vue'
+
+type ErrorHandler = (error: any) => any
+// resolve, reject arguments of Promise constructor
+type OnReadyCallback = [() => void, (reason?: any) => void]
interface ScrollBehavior {
(
scrollBehavior?: ScrollBehavior
}
-type ErrorHandler = (error: any) => any
-
-// resolve, reject arguments of Promise constructor
-type OnReadyCallback = [() => void, (reason?: any) => void]
-export class Router {
- protected history: RouterHistory
- private matcher: ReturnType<typeof createRouterMatcher>
- private beforeGuards: NavigationGuard[] = []
- private afterGuards: PostNavigationGuard[] = []
- currentRoute: Readonly<RouteLocationNormalized> = START_LOCATION_NORMALIZED
- pendingLocation: Readonly<RouteLocationNormalized> = START_LOCATION_NORMALIZED
- private app: any
- // TODO: should these be triggered before or after route.push().catch()
- private errorHandlers: ErrorHandler[] = []
- private ready: boolean = false
- private onReadyCbs: OnReadyCallback[] = []
- private scrollBehavior?: ScrollBehavior
-
- constructor(options: RouterOptions) {
- this.history = options.history
- // this.history.ensureLocation()
- this.scrollBehavior = options.scrollBehavior
+export interface Router {
+ currentRoute: Readonly<RouteLocationNormalized>
- this.matcher = createRouterMatcher(options.routes)
+ resolve(to: RouteLocation): RouteLocationNormalized
+ createHref(to: RouteLocationNormalized): string
+ push(to: RouteLocation): Promise<RouteLocationNormalized>
+ replace(to: RouteLocation): Promise<RouteLocationNormalized>
- this.history.listen(async (to, from, info) => {
- const matchedRoute = this.resolveLocation(to, this.currentRoute)
- // console.log({ to, matchedRoute })
+ // TODO: find a way to remove it
+ doInitialNavigation(): Promise<void>
+ setActiveApp(vm: Vue): void
- const toLocation: RouteLocationNormalized = { ...to, ...matchedRoute }
- this.pendingLocation = toLocation
+ beforeEach(guard: NavigationGuard): ListenerRemover
+ afterEach(guard: PostNavigationGuard): ListenerRemover
- try {
- await this.navigate(toLocation, this.currentRoute)
-
- // a more recent navigation took place
- if (this.pendingLocation !== toLocation) {
- return this.triggerError(
- new NavigationCancelled(toLocation, this.currentRoute),
- false
- )
- }
+ // TODO: also return a ListenerRemover
+ onError(handler: ErrorHandler): void
+ // TODO: change to isReady
+ onReady(): Promise<void>
+}
- // accept current navigation
- this.currentRoute = {
- ...to,
- ...matchedRoute,
- }
- this.updateReactiveRoute()
- // TODO: refactor with a state getter
- // const { scroll } = this.history.state
- const { state } = window.history
- this.handleScroll(toLocation, this.currentRoute, state.scroll).catch(
- err => this.triggerError(err, false)
- )
- } catch (error) {
- if (NavigationGuardRedirect.is(error)) {
- // TODO: refactor the duplication of new NavigationCancelled by
- // checking instanceof NavigationError (it's another TODO)
- // a more recent navigation took place
- if (this.pendingLocation !== toLocation) {
- return this.triggerError(
- new NavigationCancelled(toLocation, this.currentRoute),
- false
- )
- }
- this.triggerError(error, false)
-
- // the error is already handled by router.push
- // we just want to avoid logging the error
- this.push(error.to).catch(() => {})
- } else if (NavigationAborted.is(error)) {
- console.log('Cancelled, going to', -info.distance)
- this.history.go(-info.distance, false)
- // TODO: test on different browsers ensure consistent behavior
- // Maybe we could write the length the first time we do a navigation and use that for direction
- // TODO: this doesn't work if the user directly calls window.history.go(-n) with n > 1
- // We can override the go method to retrieve the number but not sure if all browsers allow that
- // if (info.direction === NavigationDirection.back) {
- // this.history.forward(false)
- // } else {
- // TODO: go back because we cancelled, then
- // or replace and not discard the rest of history. Check issues, there was one talking about this
- // behaviour, maybe we can do better
- // this.history.back(false)
- // }
- } else {
- this.triggerError(error, false)
- }
- }
- })
- }
+export function createRouter({
+ history,
+ routes,
+ scrollBehavior,
+}: RouterOptions): Router {
+ const matcher: ReturnType<typeof createRouterMatcher> = createRouterMatcher(
+ routes
+ )
+ const beforeGuards: NavigationGuard[] = []
+ const afterGuards: PostNavigationGuard[] = []
+ let currentRoute: Readonly<
+ RouteLocationNormalized
+ > = START_LOCATION_NORMALIZED
+ let pendingLocation: Readonly<
+ RouteLocationNormalized
+ > = START_LOCATION_NORMALIZED
+ let onReadyCbs: OnReadyCallback[] = []
+ // TODO: should these be triggered before or after route.push().catch()
+ let errorHandlers: ErrorHandler[] = []
+ let app: Vue
+ let ready: boolean = false
- resolve(
+ function resolve(
to: RouteLocation,
currentLocation?: RouteLocationNormalized /*, append?: boolean */
): RouteLocationNormalized {
if (typeof to === 'string')
- return this.resolveLocation(
+ return resolveLocation(
// TODO: refactor and remove import
normalizeLocation(to),
currentLocation
)
- return this.resolveLocation({
- // TODO: refactor with url utils
- query: {},
- hash: '',
- ...to,
- })
+ return resolveLocation(
+ {
+ // TODO: refactor with url utils
+ query: {},
+ hash: '',
+ ...to,
+ },
+ currentLocation
+ )
}
- createHref(to: RouteLocationNormalized): string {
- return this.history.base + to.fullPath
+ function createHref(to: RouteLocationNormalized): string {
+ return history.base + to.fullPath
}
- private resolveLocation(
+ function resolveLocation(
location: MatcherLocation & Required<RouteQueryAndHash>,
currentLocation?: RouteLocationNormalized,
redirectedFrom?: RouteLocationNormalized
// ensure when returning that the redirectedFrom is a normalized location
): RouteLocationNormalized {
- currentLocation = currentLocation || this.currentRoute
- const matchedRoute = this.matcher.resolve(location, currentLocation)
+ currentLocation = currentLocation || currentRoute
+ const matchedRoute = matcher.resolve(location, currentLocation)
if ('redirect' in matchedRoute) {
const { redirect } = matchedRoute
if (typeof redirect === 'string') {
// match the redirect instead
- return this.resolveLocation(
+ return resolveLocation(
normalizeLocation(redirect),
currentLocation,
normalizedLocation
const newLocation = redirect(normalizedLocation)
if (typeof newLocation === 'string') {
- return this.resolveLocation(
+ return resolveLocation(
normalizeLocation(newLocation),
currentLocation,
normalizedLocation
// there was a redirect before
// if (!('path' in newLocation) && !('name' in newLocation)) throw new Error('TODO: redirect canot be relative')
- return this.resolveLocation(
+ return resolveLocation(
{
...newLocation,
query: normalizeQuery(newLocation.query || {}),
normalizedLocation
)
} else {
- return this.resolveLocation(
+ return resolveLocation(
{
...redirect,
query: normalizeQuery(redirect.query || {}),
}
}
- /**
- * Get an array of matched components for a location. TODO: check if the array should contain plain components
- * instead of functions that return promises for lazy loaded components
- * @param to location to geth matched components from. If not provided, uses current location instead
- */
- // getMatchedComponents(
- // to?: RouteLocation | RouteLocationNormalized
- // ): RouteComponent[] {
- // const location = to
- // ? typeof to !== 'string' && 'matched' in to
- // ? to
- // : this.resolveLocation(typeof to === 'string' ? this.history.utils.normalizeLocation(to) : to)
- // : this.currentRoute
- // if (!location) return []
- // return location.matched.map(m =>
- // Object.keys(m.components).map(name => m.components[name])
- // )
- // }
-
- /**
- * Trigger a navigation, adding an entry to the history stack. Also apply all navigation
- * guards first
- * @param to where to go
- */
- async push(to: RouteLocation): Promise<RouteLocationNormalized> {
+ async function push(to: RouteLocation): Promise<RouteLocationNormalized> {
let url: HistoryLocationNormalized
let location: RouteLocationNormalized
// TODO: refactor into matchLocation to return location and url
if (typeof to === 'string' || ('path' in to && !('name' in to))) {
url = normalizeLocation(to)
// TODO: should allow a non matching url to allow dynamic routing to work
- location = this.resolveLocation(url, this.currentRoute)
+ location = resolveLocation(url, currentRoute)
} else {
// named or relative route
const query = to.query ? normalizeQuery(to.query) : {}
const hash = to.hash || ''
// we need to resolve first
- location = this.resolveLocation({ ...to, query, hash }, this.currentRoute)
+ location = resolveLocation({ ...to, query, hash }, currentRoute)
// intentionally drop current query and hash
url = normalizeLocation({
query,
// TODO: should we throw an error as the navigation was aborted
// TODO: needs a proper check because order in query could be different
if (
- this.currentRoute !== START_LOCATION_NORMALIZED &&
- this.currentRoute.fullPath === url.fullPath
+ currentRoute !== START_LOCATION_NORMALIZED &&
+ currentRoute.fullPath === url.fullPath
)
- return this.currentRoute
+ return currentRoute
const toLocation: RouteLocationNormalized = location
- this.pendingLocation = toLocation
+ pendingLocation = toLocation
// trigger all guards, throw if navigation is rejected
try {
- await this.navigate(toLocation, this.currentRoute)
+ await navigate(toLocation, currentRoute)
} catch (error) {
if (NavigationGuardRedirect.is(error)) {
// push was called while waiting in guards
- if (this.pendingLocation !== toLocation) {
+ if (pendingLocation !== toLocation) {
// TODO: trigger onError as well
- throw new NavigationCancelled(toLocation, this.currentRoute)
+ throw new NavigationCancelled(toLocation, currentRoute)
}
// TODO: setup redirect stack
// TODO: shouldn't we trigger the error as well
- return this.push(error.to)
+ return push(error.to)
} else {
// TODO: write tests
// triggerError as well
- if (this.pendingLocation !== toLocation) {
+ if (pendingLocation !== toLocation) {
// TODO: trigger onError as well
- throw new NavigationCancelled(toLocation, this.currentRoute)
+ throw new NavigationCancelled(toLocation, currentRoute)
}
- this.triggerError(error)
+ triggerError(error)
}
}
// push was called while waiting in guards
- if (this.pendingLocation !== toLocation) {
- throw new NavigationCancelled(toLocation, this.currentRoute)
+ if (pendingLocation !== toLocation) {
+ throw new NavigationCancelled(toLocation, currentRoute)
}
// change URL
- if (to.replace === true) this.history.replace(url)
- else this.history.push(url)
-
- const from = this.currentRoute
- this.currentRoute = toLocation
- this.updateReactiveRoute()
- this.handleScroll(toLocation, from).catch(err =>
- this.triggerError(err, false)
- )
+ if (to.replace === true) history.replace(url)
+ else history.push(url)
+
+ const from = currentRoute
+ currentRoute = toLocation
+ updateReactiveRoute()
+ handleScroll(toLocation, from).catch(err => triggerError(err, false))
// navigation is confirmed, call afterGuards
- for (const guard of this.afterGuards) guard(toLocation, from)
+ for (const guard of afterGuards) guard(toLocation, from)
- return this.currentRoute
+ return currentRoute
}
- /**
- * Trigger a navigation, replacing current entry in history. Also apply all navigation
- * guards first
- * @param to where to go
- */
- replace(to: RouteLocation) {
+ function replace(to: RouteLocation) {
const location = typeof to === 'string' ? { path: to } : to
- return this.push({ ...location, replace: true })
+ return push({ ...location, replace: true })
}
- /**
- * Runs a guard queue and handles redirects, rejections
- * @param guards Array of guards converted to functions that return a promise
- * @returns {boolean} true if the navigation should be cancelled false otherwise
- */
- private async runGuardQueue(guards: Lazy<any>[]): Promise<void> {
+ async function runGuardQueue(guards: Lazy<any>[]): Promise<void> {
for (const guard of guards) {
await guard()
}
}
- private async navigate(
+ async function navigate(
to: RouteLocationNormalized,
from: RouteLocationNormalized
): Promise<TODO> {
)
// run the queue of per route beforeRouteLeave guards
- await this.runGuardQueue(guards)
+ await runGuardQueue(guards)
// check global guards beforeEach
guards = []
- for (const guard of this.beforeGuards) {
+ for (const guard of beforeGuards) {
guards.push(guardToPromiseFn(guard, to, from))
}
// console.log('Guarding against', guards.length, 'guards')
- await this.runGuardQueue(guards)
+ await runGuardQueue(guards)
// check in components beforeRouteUpdate
guards = await extractComponentsGuards(
)
// run the queue of per route beforeEnter guards
- await this.runGuardQueue(guards)
+ await runGuardQueue(guards)
// check the route beforeEnter
guards = []
}
// run the queue of per route beforeEnter guards
- await this.runGuardQueue(guards)
+ await runGuardQueue(guards)
// check in-component beforeRouteEnter
// TODO: is it okay to resolve all matched component or should we do it in order
)
// run the queue of per route beforeEnter guards
- await this.runGuardQueue(guards)
+ await runGuardQueue(guards)
}
- /**
- * Add a global beforeGuard that can confirm, abort or modify a navigation
- * @param guard
- */
- beforeEach(guard: NavigationGuard): ListenerRemover {
- this.beforeGuards.push(guard)
+ history.listen(async (to, from, info) => {
+ const matchedRoute = resolveLocation(to, currentRoute)
+ // console.log({ to, matchedRoute })
+
+ const toLocation: RouteLocationNormalized = { ...to, ...matchedRoute }
+ pendingLocation = toLocation
+
+ try {
+ await navigate(toLocation, currentRoute)
+
+ // a more recent navigation took place
+ if (pendingLocation !== toLocation) {
+ return triggerError(
+ new NavigationCancelled(toLocation, currentRoute),
+ false
+ )
+ }
+
+ // accept current navigation
+ currentRoute = {
+ ...to,
+ ...matchedRoute,
+ }
+ updateReactiveRoute()
+ // TODO: refactor with a state getter
+ // const { scroll } = history.state
+ const { state } = window.history
+ handleScroll(toLocation, currentRoute, state.scroll).catch(err =>
+ triggerError(err, false)
+ )
+ } catch (error) {
+ if (NavigationGuardRedirect.is(error)) {
+ // TODO: refactor the duplication of new NavigationCancelled by
+ // checking instanceof NavigationError (it's another TODO)
+ // a more recent navigation took place
+ if (pendingLocation !== toLocation) {
+ return triggerError(
+ new NavigationCancelled(toLocation, currentRoute),
+ false
+ )
+ }
+ triggerError(error, false)
+
+ // the error is already handled by router.push
+ // we just want to avoid logging the error
+ push(error.to).catch(() => {})
+ } else if (NavigationAborted.is(error)) {
+ console.log('Cancelled, going to', -info.distance)
+ history.go(-info.distance, false)
+ // TODO: test on different browsers ensure consistent behavior
+ // Maybe we could write the length the first time we do a navigation and use that for direction
+ // TODO: this doesn't work if the user directly calls window.history.go(-n) with n > 1
+ // We can override the go method to retrieve the number but not sure if all browsers allow that
+ // if (info.direction === NavigationDirection.back) {
+ // history.forward(false)
+ // } else {
+ // TODO: go back because we cancelled, then
+ // or replace and not discard the rest of history. Check issues, there was one talking about this
+ // behaviour, maybe we can do better
+ // history.back(false)
+ // }
+ } else {
+ triggerError(error, false)
+ }
+ }
+ })
+
+ function beforeEach(guard: NavigationGuard): ListenerRemover {
+ beforeGuards.push(guard)
return () => {
- const i = this.beforeGuards.indexOf(guard)
- if (i > -1) this.beforeGuards.splice(i, 1)
+ const i = beforeGuards.indexOf(guard)
+ if (i > -1) beforeGuards.splice(i, 1)
}
}
- /**
- * Add a global after guard that is called once the navigation is confirmed
- * @param guard
- */
- afterEach(guard: PostNavigationGuard): ListenerRemover {
- this.afterGuards.push(guard)
+ function afterEach(guard: PostNavigationGuard): ListenerRemover {
+ afterGuards.push(guard)
return () => {
- const i = this.afterGuards.indexOf(guard)
- if (i > -1) this.afterGuards.splice(i, 1)
+ const i = afterGuards.indexOf(guard)
+ if (i > -1) afterGuards.splice(i, 1)
}
}
- /**
- * Add an error handler to catch errors during navigation
- * TODO: return a remover like beforeEach
- * @param handler error handler
- */
- onError(handler: ErrorHandler): void {
- this.errorHandlers.push(handler)
+ function onError(handler: ErrorHandler): void {
+ errorHandlers.push(handler)
}
- /**
- * Trigger all registered error handlers
- * @param error thrown error
- * @param shouldThrow set to false to not throw the error
- */
- private triggerError(error: any, shouldThrow: boolean = true): void {
- for (const handler of this.errorHandlers) {
+ function triggerError(error: any, shouldThrow: boolean = true): void {
+ for (const handler of errorHandlers) {
handler(error)
}
if (shouldThrow) throw error
}
- private updateReactiveRoute() {
- if (!this.app) return
+ function updateReactiveRoute() {
+ if (!app) return
// TODO: matched should be non enumerable and the defineProperty here shouldn't be necessary
- const route = { ...this.currentRoute }
+ const route = { ...currentRoute }
Object.defineProperty(route, 'matched', { enumerable: false })
- this.app._route = Object.freeze(route)
- this.markAsReady()
+ // @ts-ignore
+ app._route = Object.freeze(route)
+ markAsReady()
}
- /**
- * Returns a Promise that resolves once the router is ready to be used for navigation
- * Eg: Calling router.push() or router.replace(). This is necessary because we have to
- * wait for the Vue root instance to be created
- */
- onReady(): Promise<void> {
- if (this.ready) return Promise.resolve()
+ function onReady(): Promise<void> {
+ if (ready && currentRoute !== START_LOCATION_NORMALIZED)
+ return Promise.resolve()
return new Promise((resolve, reject) => {
- this.onReadyCbs.push([resolve, reject])
+ onReadyCbs.push([resolve, reject])
})
}
- /**
- * Mark the router as ready. This function is used internally and should not be called
- * by the developper. You can optionally provide an error.
- * This will trigger all onReady callbacks and empty the array
- * @param err optional error if navigation failed
- */
- protected markAsReady(err?: any): void {
- if (this.ready) return
- for (const [resolve] of this.onReadyCbs) {
+ function markAsReady(err?: any): void {
+ if (ready || currentRoute === START_LOCATION_NORMALIZED) return
+ ready = true
+ for (const [resolve] of onReadyCbs) {
// TODO: is this okay?
// always resolve, as the router is ready even if there was an error
// @ts-ignore
resolve(err)
+ // TODO: try catch the on ready?
// if (err) reject(err)
// else resolve()
}
- this.onReadyCbs = []
- this.ready = true
+ onReadyCbs = []
}
- // TODO: rename to ensureInitialLocation
- async doInitialNavigation(): Promise<void> {
+ async function doInitialNavigation(): Promise<void> {
// let the user call replace or push on SSR
- if (this.history.location === START) return
+ if (history.location === START) return
// TODO: refactor code that was duplicated from push method
- const toLocation: RouteLocationNormalized = this.resolveLocation(
- this.history.location,
- this.currentRoute
+ const toLocation: RouteLocationNormalized = resolveLocation(
+ history.location,
+ currentRoute
)
- this.pendingLocation = toLocation
+ pendingLocation = toLocation
// trigger all guards, throw if navigation is rejected
try {
- await this.navigate(toLocation, this.currentRoute)
+ await navigate(toLocation, currentRoute)
} catch (error) {
- this.markAsReady(error)
+ markAsReady(error)
if (NavigationGuardRedirect.is(error)) {
// push was called while waiting in guards
- if (this.pendingLocation !== toLocation) {
+ if (pendingLocation !== toLocation) {
// TODO: trigger onError as well
- throw new NavigationCancelled(toLocation, this.currentRoute)
+ throw new NavigationCancelled(toLocation, currentRoute)
}
// TODO: setup redirect stack
- await this.push(error.to)
+ await push(error.to)
return
} else {
// TODO: write tests
// triggerError as well
- if (this.pendingLocation !== toLocation) {
+ if (pendingLocation !== toLocation) {
// TODO: trigger onError as well
- throw new NavigationCancelled(toLocation, this.currentRoute)
+ throw new NavigationCancelled(toLocation, currentRoute)
}
// this throws, so nothing ahead happens
- this.triggerError(error)
+ triggerError(error)
}
}
// push was called while waiting in guards
- if (this.pendingLocation !== toLocation) {
- const error = new NavigationCancelled(toLocation, this.currentRoute)
- this.markAsReady(error)
+ if (pendingLocation !== toLocation) {
+ const error = new NavigationCancelled(toLocation, currentRoute)
+ markAsReady(error)
throw error
}
// NOTE: here we removed the pushing to history part as the history
// already contains current location
- const from = this.currentRoute
- this.currentRoute = toLocation
- this.updateReactiveRoute()
+ const from = currentRoute
+ currentRoute = toLocation
+ updateReactiveRoute()
// navigation is confirmed, call afterGuards
- for (const guard of this.afterGuards) guard(toLocation, from)
+ for (const guard of afterGuards) guard(toLocation, from)
- this.markAsReady()
+ markAsReady()
}
- private async handleScroll(
+ async function handleScroll(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
scrollPosition?: ScrollToPosition
) {
- if (!this.scrollBehavior) return
+ if (!scrollBehavior) return
- await this.app.$nextTick()
- const position = await this.scrollBehavior(to, from, scrollPosition || null)
+ await app.$nextTick()
+ const position = await scrollBehavior(to, from, scrollPosition || null)
console.log('scrolling to', position)
scrollToPosition(position)
}
+
+ function setActiveApp(vm: Vue) {
+ app = vm
+ updateReactiveRoute()
+ }
+
+ const router: Router = {
+ currentRoute,
+ push,
+ replace,
+ resolve,
+ beforeEach,
+ afterEach,
+ createHref,
+ onError,
+ onReady,
+
+ doInitialNavigation,
+ setActiveApp,
+ }
+
+ Object.defineProperty(router, 'currentRoute', {
+ get: () => currentRoute,
+ })
+
+ return router
}