]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: allow redirect in next
authorEduardo San Martin Morote <posva13@gmail.com>
Fri, 3 May 2019 14:18:14 +0000 (16:18 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Fri, 3 May 2019 14:18:14 +0000 (16:18 +0200)
__tests__/guards/global-beforeEach.spec.js
src/errors.ts
src/router.ts
src/types/index.ts
src/utils/guardToPromiseFn.ts

index af51e4d02fdcb904277a6cdf3247b35c15cf9e18..82aae142053604f4b30f2bab21edee7c7829486c 100644 (file)
@@ -23,6 +23,8 @@ const Foo = { template: `<div>Foo</div>` }
 const routes = [
   { path: '/', component: Home },
   { path: '/foo', component: Foo },
+  { path: '/other', component: Foo },
+  { path: '/n/:i', name: 'n', component: Home },
 ]
 
 describe('router.beforeEach', () => {
@@ -51,6 +53,86 @@ describe('router.beforeEach', () => {
         expect(spy).not.toHaveBeenCalled()
       })
 
+      it('can redirect to a different location', async () => {
+        const spy = jest.fn()
+        const router = createRouter({ routes })
+        await router.push('/foo')
+        spy.mockImplementation((to, from, next) => {
+          // only allow going to /other
+          if (to.fullPath !== '/other') next('/other')
+          else next()
+        })
+        router.beforeEach(spy)
+        expect(spy).not.toHaveBeenCalled()
+        await router[navigationMethod]('/')
+        expect(spy).toHaveBeenCalledTimes(2)
+        // called before redirect
+        expect(spy).toHaveBeenNthCalledWith(
+          1,
+          expect.objectContaining({ path: '/' }),
+          expect.objectContaining({ path: '/foo' }),
+          expect.any(Function)
+        )
+        expect(spy).toHaveBeenNthCalledWith(
+          2,
+          expect.objectContaining({ path: '/other' }),
+          expect.objectContaining({ path: '/foo' }),
+          expect.any(Function)
+        )
+        expect(router.currentRoute.fullPath).toBe('/other')
+      })
+
+      async function assertRedirect(redirectFn) {
+        const spy = jest.fn()
+        const router = createRouter({ routes })
+        await router.push('/')
+        spy.mockImplementation((to, from, next) => {
+          // only allow going to /other
+          const i = Number(to.params.i)
+          if (i >= 3) next()
+          else next(redirectFn(i + 1))
+        })
+        router.beforeEach(spy)
+        expect(spy).not.toHaveBeenCalled()
+        await router[navigationMethod]('/n/0')
+        expect(spy).toHaveBeenCalledTimes(4)
+        expect(router.currentRoute.fullPath).toBe('/n/3')
+      }
+
+      it('can redirect multiple times with string redirect', async () => {
+        await assertRedirect(i => '/n/' + i)
+      })
+
+      it('can redirect multiple times with path object', async () => {
+        await assertRedirect(i => ({ path: '/n/' + i }))
+      })
+
+      it('can redirect multiple times with named route', async () => {
+        await assertRedirect(i => ({ name: 'n', params: { i } }))
+      })
+
+      it('is called when changing params', async () => {
+        const spy = jest.fn()
+        const router = createRouter({ routes: [...routes] })
+        await router.push('/n/2')
+        spy.mockImplementation(noGuard)
+        router.beforeEach(spy)
+        spy.mockImplementationOnce(noGuard)
+        await router[navigationMethod]('/n/1')
+        expect(spy).toHaveBeenCalledTimes(1)
+      })
+
+      it('is not called with same params', async () => {
+        const spy = jest.fn()
+        const router = createRouter({ routes: [...routes] })
+        await router.push('/n/2')
+        spy.mockImplementation(noGuard)
+        router.beforeEach(spy)
+        spy.mockImplementationOnce(noGuard)
+        await router[navigationMethod]('/n/2')
+        expect(spy).not.toHaveBeenCalled()
+      })
+
       it('waits before navigating', async () => {
         const [promise, resolve] = fakePromise()
         const router = createRouter({ routes })
index ef6d4b9103b6410831d07da0da113c764e2eea3a..923794b7e25783869323f6c798c78599e10869a8 100644 (file)
@@ -1,6 +1,34 @@
+import { RouteLocationNormalized, RouteLocation } from './types'
+
 export class NoRouteMatchError extends Error {
   constructor(currentLocation: any, location: any) {
     super('No match for ' + JSON.stringify({ ...currentLocation, ...location }))
     Object.setPrototypeOf(this, new.target.prototype)
   }
 }
+
+/**
+ * Error used when rejecting a navigation because of a redirection. Contains
+ * information about where we where trying to go and where we are going instead
+ */
+export class RedirectError extends Error {
+  from: RouteLocationNormalized
+  to: RouteLocation
+  constructor(from: RouteLocationNormalized, to: RouteLocation) {
+    super(
+      `Redirected from "${from.fullPath}" to "${stringifyRoute(
+        to
+      )}" via a navigation guard`
+    )
+    Object.setPrototypeOf(this, new.target.prototype)
+
+    this.from = from
+    this.to = to
+  }
+}
+
+function stringifyRoute(to: RouteLocation): string {
+  if (typeof to === 'string') return to
+  if ('path' in to) return to.path
+  return 'TODO'
+}
index db28e56c7f673c6aa95de71659bd2f8c7fe22f4d..ae3e1c0dd501cf544bd82278d3f6438ace11a9d8 100644 (file)
@@ -10,9 +10,11 @@ import {
   NavigationGuard,
   TODO,
   PostNavigationGuard,
+  Lazy,
 } from './types/index'
 
-import { guardToPromiseFn, last, extractComponentsGuards } from './utils'
+import { guardToPromiseFn, extractComponentsGuards } from './utils'
+import { RedirectError } from './errors'
 
 export interface RouterOptions {
   history: BaseHistory
@@ -50,7 +52,7 @@ export class Router {
    * guards first
    * @param to where to go
    */
-  async push(to: RouteLocation) {
+  async push(to: RouteLocation): Promise<RouteLocationNormalized> {
     let url: HistoryLocationNormalized
     let location: MatcherLocationNormalized
     if (typeof to === 'string' || 'path' in to) {
@@ -69,9 +71,21 @@ export class Router {
       })
     }
 
+    // TODO: should we throw an error as the navigation was aborted
+    if (this.currentRoute.fullPath === url.fullPath) return this.currentRoute
+
     const toLocation: RouteLocationNormalized = { ...url, ...location }
     // trigger all guards, throw if navigation is rejected
-    await this.navigate(toLocation, this.currentRoute)
+    try {
+      await this.navigate(toLocation, this.currentRoute)
+    } catch (error) {
+      if (error instanceof RedirectError) {
+        // TODO: setup redirect stack
+        return this.push(error.to)
+      } else {
+        throw error
+      }
+    }
 
     // change URL
     if (to.replace === true) this.history.replace(url)
@@ -82,6 +96,8 @@ export class Router {
 
     // navigation is confirmed, call afterGuards
     for (const guard of this.afterGuards) guard(toLocation, from)
+
+    return this.currentRoute
   }
 
   /**
@@ -94,13 +110,24 @@ export class Router {
     return this.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> {
+    for (const guard of guards) {
+      await guard()
+    }
+  }
+
   private async navigate(
     to: RouteLocationNormalized,
     from: RouteLocationNormalized
   ): Promise<TODO> {
     // TODO: Will probably need to be some kind of queue in the future that allows to remove
     // elements and other stuff
-    let guards: Array<() => Promise<any>>
+    let guards: Lazy<any>[]
 
     // TODO: is it okay to resolve all matched component or should we do it in order
     // TODO: use only components that we are leaving (children)
@@ -112,26 +139,19 @@ export class Router {
     )
 
     // run the queue of per route beforeEnter guards
-    for (const guard of guards) {
-      await guard()
-    }
+    await this.runGuardQueue(guards)
 
     // check global guards beforeEach
     // avoid if we are not changing route
     // TODO: trigger on child navigation
-    // TODO: should we completely avoid a navigation towards the same route?
-    if (last(to.matched) !== last(from.matched)) {
-      guards = []
-      for (const guard of this.beforeGuards) {
-        guards.push(guardToPromiseFn(guard, to, from))
-      }
-
-      // console.log('Guarding against', guards.length, 'guards')
-      for (const guard of guards) {
-        await guard()
-      }
+    guards = []
+    for (const guard of this.beforeGuards) {
+      guards.push(guardToPromiseFn(guard, to, from))
     }
 
+    // console.log('Guarding against', guards.length, 'guards')
+    await this.runGuardQueue(guards)
+
     // check in components beforeRouteUpdate
     guards = await extractComponentsGuards(
       to.matched.filter(record => from.matched.indexOf(record) > -1),
@@ -141,9 +161,7 @@ export class Router {
     )
 
     // run the queue of per route beforeEnter guards
-    for (const guard of guards) {
-      await guard()
-    }
+    await this.runGuardQueue(guards)
 
     // check the route beforeEnter
     // TODO: check children. Should we also check reused routes guards
@@ -155,9 +173,7 @@ export class Router {
     }
 
     // run the queue of per route beforeEnter guards
-    for (const guard of guards) {
-      await guard()
-    }
+    await this.runGuardQueue(guards)
 
     // check in-component beforeRouteEnter
     // TODO: is it okay to resolve all matched component or should we do it in order
@@ -169,9 +185,7 @@ export class Router {
     )
 
     // run the queue of per route beforeEnter guards
-    for (const guard of guards) {
-      await guard()
-    }
+    await this.runGuardQueue(guards)
   }
 
   /**
index eab328be41a8013bd60c4ea69d12537c38ccb5bf..bebd9c20540072f7744d74623d058abd4dcc1744 100644 (file)
@@ -1,6 +1,6 @@
 import { HistoryQuery } from '../history/base'
 
-type Lazy<T> = () => Promise<T>
+export type Lazy<T> = () => Promise<T>
 
 export type TODO = any
 
index 1d5f93772613aedecb849539d18c9fb6660cf76c..6304eb242c9c642aa6fbca06c0f895e574108da3 100644 (file)
@@ -6,6 +6,7 @@ import {
 } from '../types'
 
 import { isRouteLocation } from './index'
+import { RedirectError } from '../errors'
 
 export function guardToPromiseFn(
   guard: NavigationGuard,
@@ -22,6 +23,7 @@ export function guardToPromiseFn(
         if (valid === false) reject(new Error('Aborted'))
         else if (isRouteLocation(valid)) {
           // TODO: redirect
+          reject(new RedirectError(to, valid))
         } else resolve()
       }