]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: add dynamic routing at router level
authorEduardo San Martin Morote <posva13@gmail.com>
Wed, 26 Feb 2020 16:10:00 +0000 (17:10 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 26 Feb 2020 16:10:00 +0000 (17:10 +0100)
__tests__/router.spec.ts
playground/router.ts
playground/views/Dynamic.vue [new file with mode: 0644]
src/matcher/index.ts
src/matcher/path-parser-ranker.ts
src/router.ts
src/types/index.ts
src/utils/query.ts

index 77f1d010afc6b5f8ed48ae4401b7257c29c73fb1..06424f1f2e9eda092ea5ca019b042cf8b482d947 100644 (file)
@@ -333,4 +333,121 @@ describe('Router', () => {
   })
 
   // it('redirects with route record redirect')
+
+  describe('Dynamic Routing', () => {
+    it('resolves new added routes', async () => {
+      const { router } = await newRouter()
+      expect(router.resolve('/new-route')).toMatchObject({
+        name: undefined,
+        matched: [],
+      })
+      router.addRoute({
+        path: '/new-route',
+        component: components.Foo,
+        name: 'new route',
+      })
+      expect(router.resolve('/new-route')).toMatchObject({
+        name: 'new route',
+      })
+    })
+
+    it('can redirect to children in the middle of navigation', async () => {
+      const { router } = await newRouter()
+      expect(router.resolve('/new-route')).toMatchObject({
+        name: undefined,
+        matched: [],
+      })
+      let removeRoute: (() => void) | undefined
+      router.addRoute({
+        path: '/dynamic',
+        component: components.Nested,
+        name: 'dynamic parent',
+        options: { end: false, strict: true },
+        beforeEnter(to, from, next) {
+          if (!removeRoute) {
+            removeRoute = router.addRoute('dynamic parent', {
+              path: 'child',
+              name: 'dynamic child',
+              component: components.Foo,
+            })
+            next(to.fullPath)
+          } else next()
+        },
+      })
+
+      router.push('/dynamic/child').catch(() => {})
+      await tick()
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'dynamic child',
+      })
+    })
+
+    it('can reroute when adding a new route', async () => {
+      const { router } = await newRouter()
+      await router.push('/p/p')
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'Param',
+      })
+      router.addRoute({
+        path: '/p/p',
+        component: components.Foo,
+        name: 'pp',
+      })
+      await router.replace(router.currentRoute.value.fullPath)
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'pp',
+      })
+    })
+
+    it('stops resolving removed routes', async () => {
+      const { router } = await newRouter()
+      // regular route
+      router.removeRoute('Foo')
+      expect(router.resolve('/foo')).toMatchObject({
+        name: undefined,
+        matched: [],
+      })
+      // dynamic route
+      const removeRoute = router.addRoute({
+        path: '/new-route',
+        component: components.Foo,
+        name: 'new route',
+      })
+      removeRoute()
+      expect(router.resolve('/new-route')).toMatchObject({
+        name: undefined,
+        matched: [],
+      })
+    })
+
+    it('can reroute when removing route', async () => {
+      const { router } = await newRouter()
+      router.addRoute({
+        path: '/p/p',
+        component: components.Foo,
+        name: 'pp',
+      })
+      await router.push('/p/p')
+      router.removeRoute('pp')
+      await router.replace(router.currentRoute.value.fullPath)
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'Param',
+      })
+    })
+
+    it('can reroute when removing route through returned function', async () => {
+      const { router } = await newRouter()
+      const remove = router.addRoute({
+        path: '/p/p',
+        component: components.Foo,
+        name: 'pp',
+      })
+      await router.push('/p/p')
+      remove()
+      await router.push('/p/p')
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'Param',
+      })
+    })
+  })
 })
index 116a70e1c510750fe746deffef94ee6a1a173376..78a9e0821c13c69ffa65f6fb827bd05f9c3141e6 100644 (file)
@@ -1,6 +1,7 @@
 import { createRouter, createWebHistory } from '../src'
 import Home from './views/Home.vue'
 import Nested from './views/Nested.vue'
+import Dynamic from './views/Dynamic.vue'
 import User from './views/User.vue'
 import NotFound from './views/NotFound.vue'
 import component from './views/Generic.vue'
@@ -9,6 +10,7 @@ import GuardedWithLeave from './views/GuardedWithLeave.vue'
 import ComponentWithData from './views/ComponentWithData.vue'
 import { globalState } from './store'
 import { scrollWaiter } from './scrollWaiter'
+let removeRoute: (() => void) | undefined
 
 // const hist = new HTML5History()
 // const hist = new HashHistory()
@@ -57,6 +59,21 @@ export const router = createRouter({
         },
       ],
     },
+    {
+      path: '/dynamic',
+      name: 'dynamic',
+      component: Nested,
+      options: { end: false, strict: true },
+      beforeEnter(to, from, next) {
+        if (!removeRoute) {
+          removeRoute = router.addRoute('dynamic', {
+            path: 'child',
+            component: Dynamic,
+          })
+          next(to.fullPath)
+        } else next()
+      },
+    },
   ],
   async scrollBehavior(to, from, savedPosition) {
     await scrollWaiter.wait()
diff --git a/playground/views/Dynamic.vue b/playground/views/Dynamic.vue
new file mode 100644 (file)
index 0000000..b63a942
--- /dev/null
@@ -0,0 +1,11 @@
+<template>
+  <div>This was added dynamically</div>
+</template>
+
+<script>
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+  name: 'Dynamic',
+})
+</script>
index b3f215a761cb0b71f8f3fe4cba89a5862cc8b412..0269bc771c94892404bcfa694bb88e9292493cb2 100644 (file)
@@ -22,8 +22,7 @@ interface RouterMatcher {
     (matcher: RouteRecordMatcher): void
     (name: Required<RouteRecord>['name']): void
   }
-  // TODO:
-  // getRoutes: () => RouteRecordMatcher[]
+  getRoutes: () => RouteRecordMatcher[]
   getRecordMatcher: (
     name: Required<RouteRecord>['name']
   ) => RouteRecordMatcher | undefined
@@ -45,6 +44,7 @@ export function createRouterMatcher(
     return matcherMap.get(name)
   }
 
+  // TODO: add routes to children of parent
   function addRoute(
     record: Readonly<RouteRecord>,
     parent?: RouteRecordMatcher
@@ -116,6 +116,10 @@ export function createRouterMatcher(
     }
   }
 
+  function getRoutes() {
+    return matchers
+  }
+
   function insertMatcher(matcher: RouteRecordMatcher) {
     let i = 0
     // console.log('i is', { i })
@@ -199,7 +203,7 @@ export function createRouterMatcher(
   // add initial routes
   routes.forEach(route => addRoute(route))
 
-  return { addRoute, resolve, removeRoute, getRecordMatcher }
+  return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
 }
 
 /**
index 93df0ff020e972666eb70ec603b2a02ba568eb2c..3ff0e324e8ee7ba6abd7dad073303834294053d4 100644 (file)
@@ -179,7 +179,7 @@ export function tokensToParser(
   }
 
   // only apply the strict bonus to the last score
-  if (options.strict) {
+  if (options.strict && options.end) {
     const i = score.length - 1
     score[i][score[i].length - 1] += PathScore.BonusStrict
   }
@@ -188,6 +188,8 @@ export function tokensToParser(
   if (!options.strict) pattern += '/?'
 
   if (options.end) pattern += '$'
+  // allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_somethingelse
+  else if (options.strict) pattern += '(?:/|$)'
 
   const re = new RegExp(pattern, options.sensitive ? '' : 'i')
 
index 54cbf6a46e055c19cc27114235f951c863c619ca..e7451b6c2c8f1deecf6dd5c81cd1db796232bb22 100644 (file)
@@ -29,8 +29,13 @@ import {
 } from './utils'
 import { useCallbacks } from './utils/callbacks'
 import { encodeParam, decode } from './utils/encoding'
-import { normalizeQuery, parseQuery, stringifyQuery } from './utils/query'
-import { ref, Ref, markNonReactive, nextTick, App } from 'vue'
+import {
+  normalizeQuery,
+  parseQuery,
+  stringifyQuery,
+  LocationQueryValue,
+} from './utils/query'
+import { ref, Ref, markNonReactive, nextTick, App, warn } from 'vue'
 import { RouteRecordNormalized } from './matcher/types'
 import { Link } from './components/Link'
 import { View } from './components/View'
@@ -58,6 +63,11 @@ export interface Router {
   history: RouterHistory
   currentRoute: Ref<Immutable<RouteLocationNormalized>>
 
+  addRoute(parentName: string, route: RouteRecord): () => void
+  addRoute(route: RouteRecord): () => void
+  removeRoute(name: string): void
+  getRoutes(): RouteRecordNormalized[]
+
   resolve(to: RouteLocation): RouteLocationNormalized
   createHref(to: RouteLocationNormalized): string
   push(to: RouteLocation): Promise<RouteLocationNormalized>
@@ -100,6 +110,33 @@ export function createRouter({
   const encodeParams = applyToParams.bind(null, encodeParam)
   const decodeParams = applyToParams.bind(null, decode)
 
+  function addRoute(parentOrRoute: string | RouteRecord, route?: RouteRecord) {
+    let parent: Parameters<typeof matcher['addRoute']>[1] | undefined
+    let record: RouteRecord
+    if (typeof parentOrRoute === 'string') {
+      parent = matcher.getRecordMatcher(parentOrRoute)
+      record = route!
+    } else {
+      record = parentOrRoute
+    }
+
+    return matcher.addRoute(record, parent)
+  }
+
+  function removeRoute(name: string) {
+    let recordMatcher = matcher.getRecordMatcher(name)
+    if (recordMatcher) {
+      matcher.removeRoute(recordMatcher)
+    } else if (__DEV__) {
+      // TODO: adapt if we allow Symbol as a name
+      warn(`Cannot remove non-existant route "${name}"`)
+    }
+  }
+
+  function getRoutes(): RouteRecordNormalized[] {
+    return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
+  }
+
   function resolve(
     location: RouteLocation,
     currentLocation?: RouteLocationNormalized
@@ -161,14 +198,12 @@ export function createRouter({
   ): Promise<RouteLocationNormalized> {
     const toLocation: RouteLocationNormalized = (pendingLocation = resolve(to))
     const from: RouteLocationNormalized = currentRoute.value
+    // @ts-ignore: no need to check the string as force do not exist on a string
+    const force: boolean | undefined = to.force
 
     // TODO: should we throw an error as the navigation was aborted
     // TODO: needs a proper check because order in query could be different
-    if (
-      from !== START_LOCATION_NORMALIZED &&
-      from.fullPath === toLocation.fullPath
-    )
-      return from
+    if (!force && isSameLocation(from, toLocation)) return from
 
     toLocation.redirectedFrom = redirectedFrom
 
@@ -427,12 +462,19 @@ export function createRouter({
 
   const router: Router = {
     currentRoute,
+
+    addRoute,
+    removeRoute,
+    getRoutes,
+
     push,
     replace,
     resolve,
+
     beforeEach: beforeGuards.add,
     afterEach: afterGuards.add,
     createHref,
+
     onError: errorHandlers.add,
     isReady,
 
@@ -497,3 +539,44 @@ function extractChangingRecords(
 
   return [leavingRecords, updatingRecords, enteringRecords]
 }
+
+function isSameLocation(
+  a: RouteLocationNormalized,
+  b: RouteLocationNormalized
+): boolean {
+  return (
+    a.name === b.name &&
+    a.path === b.path &&
+    a.hash === b.hash &&
+    isSameLocationQuery(a.query, b.query)
+  )
+}
+
+function isSameLocationQuery(
+  a: RouteLocationNormalized['query'],
+  b: RouteLocationNormalized['query']
+): boolean {
+  const aKeys = Object.keys(a)
+  const bKeys = Object.keys(b)
+  if (aKeys.length !== bKeys.length) return false
+  let i = 0
+  let key: string
+  while (i < aKeys.length) {
+    key = aKeys[i]
+    if (key !== bKeys[i]) return false
+    if (!isSameLocationQueryValue(a[key], b[key])) return false
+    i++
+  }
+
+  return true
+}
+
+function isSameLocationQueryValue(
+  a: LocationQueryValue | LocationQueryValue[],
+  b: LocationQueryValue | LocationQueryValue[]
+): boolean {
+  if (typeof a !== typeof b) return false
+  if (Array.isArray(a))
+    return a.every((value, i) => value === (b as LocationQueryValue[])[i])
+  return a === b
+}
index 00ab7e9ae87e222caa22236d949fa3124d8cdd43..e231633a6d741b48c9735571bbdb20d43d21dfe7 100644 (file)
@@ -44,7 +44,14 @@ export interface LocationAsRelative {
 }
 
 export interface RouteLocationOptions {
+  /**
+   * Replace the entry in the history instead of pushing a new entry
+   */
   replace?: boolean
+  /**
+   * Triggers the navigation even if the location is the same as the current one
+   */
+  force?: boolean
 }
 
 // User level location
index 0830707e6656a122ccdeb582597fa0fc62b90ec5..498ea7d0e6ca72d32b78c02cb54bd918d6b450ae 100644 (file)
@@ -1,6 +1,6 @@
 import { decode, encodeQueryProperty } from '../utils/encoding'
 
-type LocationQueryValue = string | null
+export type LocationQueryValue = string | null
 type LocationQueryValueRaw = LocationQueryValue | number | undefined
 export type LocationQuery = Record<
   string,