From: Eduardo San Martin Morote Date: Sun, 17 Nov 2019 17:49:05 +0000 (+0100) Subject: refactor(router): move to a function-based organization X-Git-Tag: v4.0.0-alpha.0~172 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=51cf6d91bd358eb67d67e4ee002917eb2c3851e3;p=thirdparty%2Fvuejs%2Frouter.git refactor(router): move to a function-based organization --- diff --git a/__tests__/errors.spec.ts b/__tests__/errors.spec.ts index 61b94575..5c48509f 100644 --- a/__tests__/errors.spec.ts +++ b/__tests__/errors.spec.ts @@ -1,4 +1,4 @@ -import { Router, createMemoryHistory } from '../src' +import { createRouter as newRouter, createMemoryHistory } from '../src' import { NavigationAborted, NavigationGuardRedirect } from '../src/errors' import { components, tick } from './utils' import { RouteRecord } from '../src/types' @@ -24,7 +24,7 @@ const routes: RouteRecord[] = [ const onError = jest.fn() function createRouter() { const history = createMemoryHistory() - const router = new Router({ + const router = newRouter({ history, routes, }) diff --git a/__tests__/guards/component-beforeRouteEnter.spec.ts b/__tests__/guards/component-beforeRouteEnter.spec.ts index 3c4ecda4..98e07f27 100644 --- a/__tests__/guards/component-beforeRouteEnter.spec.ts +++ b/__tests__/guards/component-beforeRouteEnter.spec.ts @@ -1,13 +1,13 @@ -import { RouterOptions } from '../../src/router' +import { RouterOptions, createRouter as newRouter } from '../../src/router' import fakePromise from 'faked-promise' import { NAVIGATION_TYPES, createDom, noGuard } from '../utils' import { RouteRecord, NavigationGuard } from '../../src/types' -import { Router, createHistory } from '../../src' +import { createHistory } from '../../src' function createRouter( options: Partial & { routes: RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/guards/component-beforeRouteLeave.spec.ts b/__tests__/guards/component-beforeRouteLeave.spec.ts index 204ddf4d..3b0479ca 100644 --- a/__tests__/guards/component-beforeRouteLeave.spec.ts +++ b/__tests__/guards/component-beforeRouteLeave.spec.ts @@ -1,4 +1,4 @@ -import { RouterOptions, Router } from '../../src/router' +import { RouterOptions, createRouter as newRouter } from '../../src/router' import { NAVIGATION_TYPES, createDom, noGuard } from '../utils' import { RouteRecord } from '../../src/types' import { createHistory } from '../../src' @@ -7,7 +7,7 @@ import { createHistory } from '../../src' function createRouter( options: Partial & { routes: RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/guards/component-beforeRouteUpdate.spec.ts b/__tests__/guards/component-beforeRouteUpdate.spec.ts index 63625ca2..7d4fedfe 100644 --- a/__tests__/guards/component-beforeRouteUpdate.spec.ts +++ b/__tests__/guards/component-beforeRouteUpdate.spec.ts @@ -1,6 +1,6 @@ import fakePromise from 'faked-promise' import { NAVIGATION_TYPES, createDom, noGuard } from '../utils' -import { Router, createHistory } from '../../src' +import { createRouter as newRouter, createHistory } from '../../src' import { RouteRecord } from '../../src/types' function createRouter( @@ -8,7 +8,7 @@ function createRouter( routes: import('../../src/types').RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/guards/global-after.spec.ts b/__tests__/guards/global-after.spec.ts index 5cd56e59..e98dde1d 100644 --- a/__tests__/guards/global-after.spec.ts +++ b/__tests__/guards/global-after.spec.ts @@ -1,12 +1,12 @@ import { NAVIGATION_TYPES, createDom } from '../utils' -import { createHistory, Router } from '../../src' +import { createHistory, createRouter as newRouter } from '../../src' function createRouter( options: Partial & { routes: import('../../src/types').RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/guards/global-beforeEach.spec.ts b/__tests__/guards/global-beforeEach.spec.ts index eb35ae3f..bc6df68f 100644 --- a/__tests__/guards/global-beforeEach.spec.ts +++ b/__tests__/guards/global-beforeEach.spec.ts @@ -2,12 +2,12 @@ import { RouterOptions } from '../../src/router' import fakePromise from 'faked-promise' import { NAVIGATION_TYPES, createDom, tick, noGuard } from '../utils' import { RouteRecord, RouteLocation } from '../../src/types' -import { createHistory, Router } from '../../src' +import { createHistory, createRouter as newRouter } from '../../src' function createRouter( options: Partial & { routes: RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/guards/route-beforeEnter.spec.ts b/__tests__/guards/route-beforeEnter.spec.ts index 51e908ce..8f98ee1b 100644 --- a/__tests__/guards/route-beforeEnter.spec.ts +++ b/__tests__/guards/route-beforeEnter.spec.ts @@ -1,4 +1,4 @@ -import { RouterOptions, Router } from '../../src/router' +import { RouterOptions, createRouter as newRouter } from '../../src/router' import fakePromise from 'faked-promise' import { NAVIGATION_TYPES, createDom, noGuard, tick } from '../utils' import { RouteRecord } from '../../src/types' @@ -7,7 +7,7 @@ import { createHistory } from '../../src' function createRouter( options: Partial & { routes: RouteRecord[] } ) { - return new Router({ + return newRouter({ history: createHistory(), ...options, }) diff --git a/__tests__/router.spec.ts b/__tests__/router.spec.ts index 35d1c0ad..489b9e48 100644 --- a/__tests__/router.spec.ts +++ b/__tests__/router.spec.ts @@ -1,5 +1,5 @@ import fakePromise from 'faked-promise' -import { Router, createMemoryHistory, createHistory } from '../src' +import { createRouter, createMemoryHistory, createHistory } from '../src' import { NavigationCancelled } from '../src/errors' import { createDom, components, tick } from './utils' import { RouteRecord, RouteLocation } from '../src/types' @@ -23,10 +23,6 @@ const routes: RouteRecord[] = [ }, ] -function createRouter(...options: ConstructorParameters) { - return new Router(...options) -} - describe('Router', () => { beforeAll(() => { createDom() diff --git a/__tests__/ssr/shared.ts b/__tests__/ssr/shared.ts index d0d80b83..9392cc56 100644 --- a/__tests__/ssr/shared.ts +++ b/__tests__/ssr/shared.ts @@ -1,5 +1,9 @@ import Vue, { ComponentOptions } from 'vue' -import { Router, createMemoryHistory, plugin } from '../../src' +import { + createRouter as newRouter, + createMemoryHistory, + plugin, +} from '../../src' import { components } from '../utils' import { createRenderer } from 'vue-server-renderer' @@ -11,7 +15,7 @@ export const renderer = createRenderer() export function createRouter(options?: Partial) { // TODO: a more complex routing that can be used for most tests - return new Router({ + return newRouter({ history: createMemoryHistory(), routes: [ { @@ -56,11 +60,6 @@ export function renderApp( return new Promise['app']>((resolve, reject) => { const { app, router } = createApp(routerOptions, vueOptions) - // set server-side router's location - router.push(context.url).catch(err => { - console.error('ssr push failed', err) - }) - // wait until router has resolved possible async components and hooks // TODO: rename the promise one to isReady router.onReady().then(() => { @@ -74,5 +73,10 @@ export function renderApp( // the Promise should resolve to the app instance so it can be rendered resolve(app) }, reject) + + // set server-side router's location + router.push(context.url).catch(err => { + console.error('ssr push failed', err) + }) }) } diff --git a/__tests__/url-encoding.spec.ts b/__tests__/url-encoding.spec.ts index ca39a2a8..c770db50 100644 --- a/__tests__/url-encoding.spec.ts +++ b/__tests__/url-encoding.spec.ts @@ -1,4 +1,4 @@ -import { Router } from '../src/router' +import { createRouter } from '../src/router' import { createDom, components } from './utils' import { RouteRecord } from '../src/types' import { createMemoryHistory } from '../src' @@ -25,7 +25,7 @@ describe('URL Encoding', () => { describe('initial navigation', () => { it('decodes path', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.replace('/%25') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -39,7 +39,7 @@ describe('URL Encoding', () => { it('decodes params in path', async () => { // /p/€ const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/p/%E2%82%AC') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -53,7 +53,7 @@ describe('URL Encoding', () => { it('allows navigating to valid unencoded params (IE and Edge)', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/p/€') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -70,7 +70,7 @@ describe('URL Encoding', () => { it('allows navigating to invalid unencoded params (IE and Edge)', async () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/p/%notvalid') expect(spy).toHaveBeenCalledTimes(1) spy.mockRestore() @@ -88,7 +88,7 @@ describe('URL Encoding', () => { it('decodes params in query', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/?q=%25%E2%82%AC') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -104,7 +104,7 @@ describe('URL Encoding', () => { it('decodes params keys in query', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/?%E2%82%AC=euro') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -121,7 +121,7 @@ describe('URL Encoding', () => { it('allow unencoded params in query (IE Edge)', async () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/?q=€%notvalid') expect(spy).toHaveBeenCalledTimes(1) spy.mockRestore() @@ -142,7 +142,7 @@ describe('URL Encoding', () => { // encoding it. To be safe we would have to encode everything it.skip('decodes hash', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/#%25%E2%82%AC') expect(router.currentRoute).toEqual( expect.objectContaining({ @@ -157,7 +157,7 @@ describe('URL Encoding', () => { it('allow unencoded params in query (IE Edge)', async () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push('/?q=€%notvalid') expect(spy).toHaveBeenCalledTimes(1) spy.mockRestore() @@ -177,7 +177,7 @@ describe('URL Encoding', () => { describe('resolving locations', () => { it('encodes params when resolving', async () => { const history = createHistory() - const router = new Router({ history, routes }) + const router = createRouter({ history, routes }) await router.push({ name: 'params', params: { p: '%€' } }) expect(router.currentRoute).toEqual( expect.objectContaining({ diff --git a/explorations/html5.ts b/explorations/html5.ts index 086ec8aa..bb1adad4 100644 --- a/explorations/html5.ts +++ b/explorations/html5.ts @@ -1,5 +1,5 @@ import { - Router, + createRouter, plugin, // @ts-ignore createHistory, @@ -16,7 +16,7 @@ declare global { vm: Vue // h: HTML5History h: ReturnType - r: Router + r: ReturnType } } @@ -33,6 +33,10 @@ const component: RouteComponent = { template: `
A component
`, } +const NotFound: RouteComponent = { + template: `
Not Found: {{ $route.fullPath }}
`, +} + const Home: RouteComponent = { template: `
Home
`, } @@ -110,7 +114,7 @@ const scrollWaiter = new ScrollQueue() // const hist = new HTML5History() // const hist = new HashHistory() -const router = new Router({ +const router = createRouter({ history: routerHistory, routes: [ { path: '/', component: Home, name: 'home', alias: '/home' }, @@ -141,7 +145,7 @@ const router = new Router({ }, { path: '/with-data', component: ComponentWithData, name: 'WithData' }, { path: '/rep/:a*', component: component, name: 'repeat' }, - // { path: /^\/about\/?$/, component }, + { path: '/:data(.*)', component: NotFound, name: 'NotFound' }, ], async scrollBehavior(to, from, savedPosition) { await scrollWaiter.wait() diff --git a/src/index.ts b/src/index.ts index 10468236..3af17641 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -import { Router, RouterOptions } from './router' -import { PluginFunction, VueConstructor } from 'vue' +import { createRouter, Router } from './router' +import { PluginFunction } from 'vue' import createHistory from './history/html5' import createMemoryHistory from './history/memory' import createHashHistory from './history/hash' @@ -19,8 +19,7 @@ const plugin: PluginFunction = Vue => { // @ts-ignore _router is internal this._router = router // this._router.init(this) - // @ts-ignore - this._router.app = this + router.setActiveApp(this) // @ts-ignore we can use but should not be used by others Vue.util.defineReactive( this, @@ -64,7 +63,13 @@ const plugin: PluginFunction = Vue => { strats.created } -export { Router, createHistory, createMemoryHistory, createHashHistory, plugin } +export { + createRouter, + createHistory, + createMemoryHistory, + createHashHistory, + plugin, +} // TODO: refactor somewhere else // const inBrowser = typeof window !== 'undefined' @@ -75,30 +80,30 @@ export { Router, createHistory, createMemoryHistory, createHashHistory, plugin } // abstract: AbstractHistory // } -export default class VueRouter extends Router { - static install = plugin - static version = '__VERSION__' +// export default class VueRouter extends Router { +// static install = plugin +// static version = '__VERSION__' - // TODO: handle mode in a retro compatible way - constructor( - options: Partial - ) { - // let { mode } = options - // if (!inBrowser) mode = 'abstract' - super({ - ...options, - routes: options.routes || [], - // FIXME: change when possible - history: createHistory(), - // history: new HistoryMode[mode || 'hash'](), - }) - } -} +// // TODO: handle mode in a retro compatible way +// constructor( +// options: Partial +// ) { +// // let { mode } = options +// // if (!inBrowser) mode = 'abstract' +// super({ +// ...options, +// routes: options.routes || [], +// // FIXME: change when possible +// history: createHistory(), +// // history: new HistoryMode[mode || 'hash'](), +// }) +// } +// } -declare global { - interface Window { - Vue: VueConstructor - } -} +// declare global { +// interface Window { +// Vue: VueConstructor +// } +// } -if (typeof window !== 'undefined' && window.Vue) window.Vue.use(VueRouter) +// if (typeof window !== 'undefined' && window.Vue) window.Vue.use(VueRouter) diff --git a/src/matcher/index.ts b/src/matcher/index.ts index cf59f4f0..d2c8a74a 100644 --- a/src/matcher/index.ts +++ b/src/matcher/index.ts @@ -14,6 +14,7 @@ import { RouteRecordMatcher, RouteRecordNormalized } from './types' interface RouterMatcher { addRoute: (record: Readonly, parent?: RouteRecordMatcher) => void + // TODO: remove route resolve: ( location: Readonly, currentLocation: Readonly diff --git a/src/router.ts b/src/router.ts index 72beb981..26366196 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,37 +1,41 @@ 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 { ( @@ -47,129 +51,82 @@ export interface RouterOptions { 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 - private beforeGuards: NavigationGuard[] = [] - private afterGuards: PostNavigationGuard[] = [] - currentRoute: Readonly = START_LOCATION_NORMALIZED - pendingLocation: Readonly = 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 - this.matcher = createRouterMatcher(options.routes) + resolve(to: RouteLocation): RouteLocationNormalized + createHref(to: RouteLocationNormalized): string + push(to: RouteLocation): Promise + replace(to: RouteLocation): Promise - 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 + 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 +} - // 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 = 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, 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 @@ -189,7 +146,7 @@ export class Router { if (typeof redirect === 'string') { // match the redirect instead - return this.resolveLocation( + return resolveLocation( normalizeLocation(redirect), currentLocation, normalizedLocation @@ -198,7 +155,7 @@ export class Router { const newLocation = redirect(normalizedLocation) if (typeof newLocation === 'string') { - return this.resolveLocation( + return resolveLocation( normalizeLocation(newLocation), currentLocation, normalizedLocation @@ -209,7 +166,7 @@ export class Router { // 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 || {}), @@ -219,7 +176,7 @@ export class Router { normalizedLocation ) } else { - return this.resolveLocation( + return resolveLocation( { ...redirect, query: normalizeQuery(redirect.query || {}), @@ -244,44 +201,20 @@ export class Router { } } - /** - * 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 { + async function push(to: RouteLocation): Promise { 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, @@ -293,82 +226,70 @@ export class Router { // 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[]): Promise { + async function runGuardQueue(guards: Lazy[]): Promise { for (const guard of guards) { await guard() } } - private async navigate( + async function navigate( to: RouteLocationNormalized, from: RouteLocationNormalized ): Promise { @@ -383,16 +304,16 @@ export class Router { ) // 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( @@ -403,7 +324,7 @@ export class Router { ) // run the queue of per route beforeEnter guards - await this.runGuardQueue(guards) + await runGuardQueue(guards) // check the route beforeEnter guards = [] @@ -420,7 +341,7 @@ export class Router { } // 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 @@ -432,163 +353,229 @@ export class Router { ) // 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 { - if (this.ready) return Promise.resolve() + function onReady(): Promise { + 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 { + async function doInitialNavigation(): Promise { // 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 }