]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: createHistory wip
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 8 Oct 2019 09:06:13 +0000 (11:06 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 8 Oct 2019 09:12:43 +0000 (11:12 +0200)
.prettierrc [new file with mode: 0644]
__tests__/guards/component-beforeRouteEnter.spec.ts
explorations/html5.ts
src/history/common.ts [new file with mode: 0644]
src/history/html5.2.ts [new file with mode: 0644]
src/index.ts
src/router.ts

diff --git a/.prettierrc b/.prettierrc
new file mode 100644 (file)
index 0000000..9d313aa
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "semi": false,
+  "trailingComma": "es5",
+  "singleQuote": true
+}
index 8d5213122471204dc082bf4e9cf95d696746a488..f2aaa52fd86ff0b960ae62e50d293a84432ed0b4 100644 (file)
@@ -9,7 +9,7 @@ function createRouter(
 ) {
   return new Router({
     history: new HTML5History(),
-    ...options
+    ...options,
   })
 }
 
@@ -22,7 +22,7 @@ const beforeRouteEnter = jest.fn<
 >()
 const named = {
   default: jest.fn(),
-  other: jest.fn()
+  other: jest.fn(),
 }
 
 const nested = {
@@ -32,7 +32,7 @@ const nested = {
   nestedAbs: jest.fn(),
   nestedNested: jest.fn(),
   nestedNestedFoo: jest.fn(),
-  nestedNestedParam: jest.fn()
+  nestedNestedParam: jest.fn(),
 }
 
 const routes: RouteRecord[] = [
@@ -42,43 +42,43 @@ const routes: RouteRecord[] = [
     path: '/guard/:n',
     component: {
       ...Foo,
-      beforeRouteEnter
-    }
+      beforeRouteEnter,
+    },
   },
   {
     path: '/named',
     components: {
       default: {
         ...Home,
-        beforeRouteEnter: named.default
+        beforeRouteEnter: named.default,
       },
       other: {
         ...Foo,
-        beforeRouteEnter: named.other
-      }
-    }
+        beforeRouteEnter: named.other,
+      },
+    },
   },
   {
     path: '/nested',
     component: {
       ...Home,
-      beforeRouteEnter: nested.parent
+      beforeRouteEnter: nested.parent,
     },
     children: [
       {
         path: '',
         name: 'nested-empty-path',
-        component: { ...Home, beforeRouteEnter: nested.nestedEmpty }
+        component: { ...Home, beforeRouteEnter: nested.nestedEmpty },
       },
       {
         path: 'a',
         name: 'nested-path',
-        component: { ...Home, beforeRouteEnter: nested.nestedA }
+        component: { ...Home, beforeRouteEnter: nested.nestedA },
       },
       {
         path: '/abs-nested',
         name: 'absolute-nested',
-        component: { ...Home, beforeRouteEnter: nested.nestedAbs }
+        component: { ...Home, beforeRouteEnter: nested.nestedAbs },
       },
       {
         path: 'nested',
@@ -88,17 +88,17 @@ const routes: RouteRecord[] = [
           {
             path: 'foo',
             name: 'nested-nested-foo',
-            component: { ...Home, beforeRouteEnter: nested.nestedNestedFoo }
+            component: { ...Home, beforeRouteEnter: nested.nestedNestedFoo },
           },
           {
             path: 'param/:p',
             name: 'nested-nested-param',
-            component: { ...Home, beforeRouteEnter: nested.nestedNestedParam }
-          }
-        ]
-      }
-    ]
-  }
+            component: { ...Home, beforeRouteEnter: nested.nestedNestedParam },
+          },
+        ],
+      },
+    ],
+  },
 ]
 
 function resetMocks() {
@@ -196,11 +196,11 @@ describe('beforeRouteEnter', () => {
         const spy = jest.fn(noGuard)
         const component = {
           template: `<div></div>`,
-          beforeRouteEnter: spy
+          beforeRouteEnter: spy,
         }
         const [promise, resolve] = fakePromise<typeof component>()
         const router = createRouter({
-          routes: [...routes, { path: '/async', component: () => promise }]
+          routes: [...routes, { path: '/async', component: () => promise }],
         })
         const pushPromise = router[navigationMethod]('/async')
         expect(spy).not.toHaveBeenCalled()
index 1e8849c6f67e7be2345a1cf13d5be0bb37473cee..13d2f814f53b9348103297afb060cb50e5148cff 100644 (file)
@@ -7,7 +7,7 @@ import {
   // @ts-ignore
   AbstractHistory,
   plugin,
-  BaseHistory,
+  createHistory,
 } from '../src'
 import { RouteComponent } from '../src/types'
 import Vue from 'vue'
@@ -16,11 +16,14 @@ declare global {
   interface Window {
     vm: Vue
     // h: HTML5History
-    h: BaseHistory
+    h: ReturnType<typeof createHistory>
     r: Router
   }
 }
 
+const routerHistory = createHistory()
+window.h = routerHistory
+
 const shared = {
   cancel: false,
 }
@@ -104,10 +107,10 @@ class ScrollQueue {
 
 const scrollWaiter = new ScrollQueue()
 
-const hist = new HTML5History()
+// const hist = new HTML5History()
 // const hist = new HashHistory()
 const router = new Router({
-  history: hist,
+  history: routerHistory,
   routes: [
     { path: '/', component: Home, name: 'home' },
     { path: '/users/:id', name: 'user', component: User },
@@ -149,14 +152,11 @@ const router = new Router({
 })
 
 // for testing purposes
-const r = router
-const h = hist
-window.h = h
-window.r = r
+window.r = router
 
 const delay = (t: number) => new Promise(resolve => setTimeout(resolve, t))
 
-r.beforeEach(async (to, from, next) => {
+router.beforeEach(async (to, from, next) => {
   console.log(`Guard from ${from.fullPath} to ${to.fullPath}`)
   if (to.params.id === 'no-name') return next(false)
 
@@ -168,12 +168,12 @@ r.beforeEach(async (to, from, next) => {
   next()
 })
 
-r.beforeEach((to, from, next) => {
+router.beforeEach((to, from, next) => {
   if (shared.cancel) return next(false)
   next()
 })
 
-r.afterEach((to, from) => {
+router.afterEach((to, from) => {
   console.log(
     `After guard: from ${from.fullPath} to ${
       to.fullPath
@@ -181,38 +181,38 @@ r.afterEach((to, from) => {
   )
 })
 
-r.beforeEach((to, from, next) => {
+router.beforeEach((to, from, next) => {
   console.log('second guard')
   next()
 })
 
-h.listen((to, from, { direction }) => {
-  console.log(`popstate(${direction})`, { to, from })
+routerHistory.listen((to, from, info) => {
+  console.log(`popstate(${info})`, { to, from })
 })
 
 async function run() {
-  // r.push('/multiple/one/two')
+  // router.push('/multiple/one/two')
   // h.push('/hey')
   // h.push('/hey?lol')
   // h.push('/foo')
   // h.push('/replace-me')
   // h.replace('/bar')
-  // r.push('/about')
-  // await r.push('/')
-  // await r.push({
+  // router.push('/about')
+  // await router.push('/')
+  // await router.push({
   //   name: 'user',
   //   params: {
   //     id: '6',
   //   },
   // })
-  // await r.push({
+  // await router.push({
   //   name: 'user',
   //   params: {
   //     id: '5',
   //   },
   // })
   // try {
-  //   await r.push({
+  //   await router.push({
   //     params: {
   //       id: 'no-name',
   //     },
@@ -220,13 +220,13 @@ async function run() {
   // } catch (err) {
   //   console.log('Navigation aborted', err)
   // }
-  // await r.push({
+  // await router.push({
   //   hash: '#hey',
   // })
-  // await r.push('/children')
-  // await r.push('/children/a')
-  // await r.push('/children/b')
-  // await r.push({ name: 'a-child' })
+  // await router.push('/children')
+  // await router.push('/children/a')
+  // await router.push('/children/b')
+  // await router.push({ name: 'a-child' })
 }
 
 // use the router
@@ -262,5 +262,3 @@ window.vm = new Vue({
 })
 
 run()
-
-
diff --git a/src/history/common.ts b/src/history/common.ts
new file mode 100644 (file)
index 0000000..a5c4ee8
--- /dev/null
@@ -0,0 +1,255 @@
+import { ListenerRemover } from '../types'
+
+type HistoryQuery = Record<string, string | string[]>
+// TODO: is it reall worth allowing null to form queries like ?q&b&c
+// When parsing using URLSearchParams, `q&c=` yield an empty string for q and c
+// I think it's okay to allow this by default and allow extending it
+// a more permissive history query
+// TODO: allow numbers
+type RawHistoryQuery = Record<string, string | string[] | null>
+
+interface HistoryLocation {
+  // pathname section
+  path: string
+  // search string parsed
+  query?: RawHistoryQuery
+  // hash with the #
+  hash?: string
+}
+
+type RawHistoryLocation = HistoryLocation | string
+
+export interface HistoryLocationNormalized extends Required<HistoryLocation> {
+  // full path (like href)
+  fullPath: string
+  query: HistoryQuery
+}
+
+// pushState clones the state passed and do not accept everything
+// it doesn't accept symbols, nor functions as values. It also ignores Symbols as keys
+type HistoryStateValue =
+  | string
+  | number
+  | boolean
+  | null
+  | HistoryState
+  | HistoryStateArray
+
+interface HistoryState {
+  [x: number]: HistoryStateValue
+  [x: string]: HistoryStateValue
+}
+interface HistoryStateArray extends Array<HistoryStateValue> {}
+
+export enum NavigationType {
+  pop = 'pop',
+  push = 'push',
+}
+
+export interface NavigationCallback {
+  (
+    to: HistoryLocationNormalized,
+    from: HistoryLocationNormalized,
+    information: { type: NavigationType }
+  ): void
+}
+
+export interface RouterHistory {
+  location: HistoryLocationNormalized
+  push(to: RawHistoryLocation, data?: any): void
+  replace(to: RawHistoryLocation): void
+  listen(callback: NavigationCallback): ListenerRemover
+
+  // back(triggerListeners?: boolean): void
+  // forward(triggerListeners?: boolean): void
+
+  destroy(): void
+}
+
+// Generic utils
+
+// needed for the global flag
+const PERCENT_RE = /%/g
+
+/**
+ * Transforms an URI into a normalized history location
+ * @param location URI to normalize
+ * @returns a normalized history location
+ */
+export function parseURL(location: string): HistoryLocationNormalized {
+  let path = '',
+    query: HistoryQuery = {},
+    searchString = '',
+    hash = ''
+
+  // Could use URL and URLSearchParams but IE 11 doesn't support it
+  const searchPos = location.indexOf('?')
+  const hashPos = location.indexOf('#', searchPos > -1 ? searchPos : 0)
+
+  if (searchPos > -1) {
+    path = location.slice(0, searchPos)
+    searchString = location.slice(
+      searchPos + 1,
+      hashPos > -1 ? hashPos : location.length
+    )
+
+    // TODO: can we remove the normalize call?
+    query = normalizeQuery(parseQuery(searchString))
+  }
+
+  if (hashPos > -1) {
+    path = path || location.slice(0, hashPos)
+    // keep the # character
+    hash = location.slice(hashPos, location.length)
+  }
+
+  // no search and no query
+  path = path || location
+
+  return {
+    fullPath: location,
+    path,
+    query,
+    hash,
+  }
+}
+
+function safeDecodeUriComponent(value: string): string {
+  try {
+    value = decodeURIComponent(value)
+  } catch (err) {
+    // TODO: handling only URIError?
+    console.warn(
+      `[vue-router] error decoding query "${value}". Keeping the original value.`
+    )
+  }
+
+  return value
+}
+
+function safeEncodeUriComponent(value: string): string {
+  try {
+    value = encodeURIComponent(value)
+  } catch (err) {
+    // TODO: handling only URIError?
+    console.warn(
+      `[vue-router] error encoding query "${value}". Keeping the original value.`
+    )
+  }
+
+  return value
+}
+
+/**
+ * Transform a queryString into a query object. Accept both, a version with the leading `?` and without
+ * Should work as URLSearchParams
+ * @param search
+ * @returns a query object
+ */
+export function parseQuery(search: string): HistoryQuery {
+  const hasLeadingIM = search[0] === '?'
+  const query: HistoryQuery = {}
+  // avoid creating an object with an empty key and empty value
+  // because of split('&')
+  if (search === '' || search === '?') return query
+  const searchParams = (hasLeadingIM ? search.slice(1) : search).split('&')
+  for (let i = 0; i < searchParams.length; ++i) {
+    let [key, value] = searchParams[i].split('=')
+    key = safeDecodeUriComponent(key)
+    value = safeDecodeUriComponent(value)
+    if (key in query) {
+      // an extra variable for ts types
+      let currentValue = query[key]
+      if (!Array.isArray(currentValue)) {
+        currentValue = query[key] = [currentValue]
+      }
+      currentValue.push(value)
+    } else {
+      query[key] = value
+    }
+  }
+  return query
+}
+
+/**
+ * Stringify a URL object
+ * @param location
+ */
+export function stringifyURL(location: HistoryLocation): string {
+  let url = location.path
+  let query = location.query ? stringifyQuery(location.query) : ''
+
+  return url + (query && '?' + query) + (location.hash || '')
+}
+
+/**
+ * Stringify an object query. Works like URLSearchParams. Doesn't prepend a `?`
+ * @param query
+ */
+export function stringifyQuery(query: RawHistoryQuery): string {
+  let search = ''
+  // TODO: util function?
+  for (const key in query) {
+    if (search.length > 1) search += '&'
+    const value = query[key]
+    if (value === null) {
+      // TODO: should we just add the empty string value?
+      search += key
+      continue
+    }
+    let encodedKey = safeEncodeUriComponent(key)
+    let values: string[] = Array.isArray(value) ? value : [value]
+    values = values.map(safeEncodeUriComponent)
+
+    search += `${encodedKey}=${values[0]}`
+    for (let i = 1; i < values.length; i++) {
+      search += `&${encodedKey}=${values[i]}`
+    }
+  }
+
+  return search
+}
+
+export function normalizeQuery(query: RawHistoryQuery): HistoryQuery {
+  // TODO: implem
+  const normalizedQuery: HistoryQuery = {}
+  for (const key in query) {
+    const value = query[key]
+    if (value === null) normalizedQuery[key] = ''
+    else normalizedQuery[key] = value
+  }
+  return normalizedQuery
+}
+
+/**
+ * Prepare a URI string to be passed to pushState
+ * @param uri
+ */
+export function prepareURI(uri: string) {
+  // encode the % symbol so it also works on IE
+  return uri.replace(PERCENT_RE, '%25')
+}
+
+// use regular decodeURI
+// Use a renamed export instead of global.decodeURI
+// to support node and browser at the same time
+const originalDecodeURI = decodeURI
+export { originalDecodeURI as decodeURI }
+
+/**
+ * Normalize a History location into an object that looks like
+ * the one at window.location
+ * @param location
+ */
+export function normalizeLocation(
+  location: RawHistoryLocation
+): HistoryLocationNormalized {
+  if (typeof location === 'string') return parseURL(location)
+  else
+    return {
+      fullPath: stringifyURL(location),
+      path: location.path,
+      query: location.query ? normalizeQuery(location.query) : {},
+      hash: location.hash || '',
+    }
+}
diff --git a/src/history/html5.2.ts b/src/history/html5.2.ts
new file mode 100644 (file)
index 0000000..747b005
--- /dev/null
@@ -0,0 +1,164 @@
+import {
+  RouterHistory,
+  NavigationCallback,
+  parseQuery,
+  normalizeLocation,
+  NavigationType
+} from './common'
+import { HistoryLocationNormalized, HistoryState } from './base'
+import { computeScrollPosition, ScrollToPosition } from '../utils/scroll'
+// import consola from 'consola'
+
+const cs = console
+
+type PopStateListener = (this: Window, ev: PopStateEvent) => any
+
+interface StateEntry {
+  back: HistoryLocationNormalized | null
+  current: HistoryLocationNormalized
+  forward: HistoryLocationNormalized | null
+  replaced: boolean
+  scroll: ScrollToPosition | null
+}
+
+export default function createHistory(): RouterHistory {
+  const { history } = window
+
+  /**
+   * Creates a noramlized history location from a window.location object
+   * TODO: encoding is not handled like this
+   * @param location
+   */
+  function createCurrentLocation(
+    location: Location
+  ): HistoryLocationNormalized {
+    return {
+      fullPath: location.pathname + location.search + location.hash,
+      path: location.pathname,
+      query: parseQuery(location.search),
+      hash: location.hash
+    }
+  }
+
+  /**
+   * Creates a state objec
+   */
+  function buildState(
+    back: HistoryLocationNormalized | null,
+    current: HistoryLocationNormalized,
+    forward: HistoryLocationNormalized | null,
+    replaced: boolean = false,
+    computeScroll: boolean = false
+  ): StateEntry {
+    return {
+      back,
+      current,
+      forward,
+      replaced,
+      scroll: computeScroll ? computeScrollPosition() : null
+    }
+  }
+
+  // private state of History
+  let location: HistoryLocationNormalized = normalizeLocation(
+    window.location.href
+  )
+  let listeners: NavigationCallback[] = []
+  let teardowns: Array<() => void> = []
+  // TODO: should it be a stack? a Dict. Check if the popstate listener
+  // can trigger twice
+
+  const popStateHandler: PopStateListener = ({
+    state
+  }: {
+    state: StateEntry
+  }) => {
+    cs.info('popstate fired', { state, location })
+
+    // TODO: handle go(-2) and go(2) (skipping entries)
+
+    const from = location
+    location = createCurrentLocation(window.location)
+
+    // call all listeners
+    listeners.forEach(listener =>
+      listener(location, from, {
+        type: NavigationType.pop
+      })
+    )
+  }
+
+  // settup the listener and prepare teardown callbacks
+  window.addEventListener('popstate', popStateHandler)
+
+  function changeLocation(
+    state: StateEntry,
+    title: string,
+    url: string,
+    replace: boolean
+  ): void {
+    try {
+      // BROWSER QUIRK
+      // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
+      history[replace ? 'replaceState' : 'pushState'](state, title, url)
+    } catch (err) {
+      cs.log('[vue-router]: Error with push/replace State', err)
+      // Force the navigation, this also resets the call count
+      window.location[replace ? 'replace' : 'assign'](url)
+    }
+  }
+
+  return {
+    location,
+
+    replace(to) {
+      const normalized = normalizeLocation(to)
+
+      cs.info('replace', location, normalized)
+
+      changeLocation(
+        buildState(history.state.back, normalized, null, true),
+        '',
+        normalized.fullPath,
+        true
+      )
+      location = normalized
+    },
+
+    push(to, data?: HistoryState) {
+      const normalized = normalizeLocation(to)
+
+      // Add to current entry the information of where we are going
+      history.state.forward = normalized
+
+      const state = {
+        ...buildState(location, normalized, null),
+        ...data
+      }
+
+      cs.info('push', location, '->', normalized, 'with state', state)
+
+      changeLocation(state, '', normalized.fullPath, false)
+      location = normalized
+    },
+
+    listen(callback) {
+      // settup the listener and prepare teardown callbacks
+      listeners.push(callback)
+
+      const teardown = () => {
+        const index = listeners.indexOf(callback)
+        if (index > -1) listeners.splice(index, 1)
+      }
+
+      teardowns.push(teardown)
+      return teardown
+    },
+
+    destroy() {
+      for (const teardown of teardowns) teardown()
+      teardowns = []
+      window.removeEventListener('popstate', popStateHandler)
+    }
+  }
+}
index 66033ec73038b6c1e6c37338f373b017800ce9da..519bb126dedae04a31fb454cd55baf442e36e7e5 100644 (file)
@@ -4,6 +4,7 @@ import { HashHistory } from './history/hash'
 import { AbstractHistory } from './history/abstract'
 import { BaseHistory } from './history/base'
 import { PluginFunction, VueConstructor } from 'vue'
+import createHistory from './history/html5.2'
 import View from './components/View'
 import Link from './components/Link'
 
@@ -73,13 +74,15 @@ export {
 }
 
 // TODO: refactor somewhere else
-const inBrowser = typeof window !== 'undefined'
+// const inBrowser = typeof window !== 'undefined'
 
-const HistoryMode = {
-  history: HTML5History,
-  hash: HashHistory,
-  abstract: AbstractHistory,
-}
+// const HistoryMode = {
+//   history: HTML5History,
+//   hash: HashHistory,
+//   abstract: AbstractHistory
+// }
+
+export { createHistory }
 
 export default class VueRouter extends Router {
   static install = plugin
@@ -89,12 +92,14 @@ export default class VueRouter extends Router {
   constructor(
     options: Partial<RouterOptions & { mode: 'history' | 'abstract' | 'hash' }>
   ) {
-    let { mode } = options
-    if (!inBrowser) mode = 'abstract'
+    // let { mode } = options
+    // if (!inBrowser) mode = 'abstract'
     super({
       ...options,
       routes: options.routes || [],
-      history: new HistoryMode[mode || 'hash'](),
+      // FIXME: change when possible
+      history: createHistory(),
+      // history: new HistoryMode[mode || 'hash'](),
     })
   }
 }
index 926356dfdd0d5646f8f0632296dbd2ef98f4dd5f..b8caabccf5e663f91fd83753613675592522bbfb 100644 (file)
@@ -1,8 +1,10 @@
 import {
-  BaseHistory,
-  HistoryLocationNormalized,
-  NavigationDirection,
-} from './history/base'
+  normalizeLocation,
+  RouterHistory,
+  stringifyURL,
+  normalizeQuery,
+  HistoryLocationNormalized
+} from './history/common'
 import { RouterMatcher } from './matcher'
 import {
   RouteLocation,
@@ -15,19 +17,19 @@ import {
   PostNavigationGuard,
   Lazy,
   MatcherLocation,
-  RouteQueryAndHash,
+  RouteQueryAndHash
 } from './types/index'
 import {
   ScrollToPosition,
   ScrollPosition,
-  scrollToPosition,
+  scrollToPosition
 } from './utils/scroll'
 
 import { guardToPromiseFn, extractComponentsGuards } from './utils'
 import {
   NavigationGuardRedirect,
   NavigationAborted,
-  NavigationCancelled,
+  NavigationCancelled
 } from './errors'
 
 interface ScrollBehavior {
@@ -39,7 +41,7 @@ interface ScrollBehavior {
 }
 
 export interface RouterOptions {
-  history: BaseHistory
+  history: RouterHistory
   routes: RouteRecord[]
   // TODO: async version
   scrollBehavior?: ScrollBehavior
@@ -50,7 +52,7 @@ type ErrorHandler = (error: any) => any
 // resolve, reject arguments of Promise constructor
 type OnReadyCallback = [() => void, (reason?: any) => void]
 export class Router {
-  protected history: BaseHistory
+  protected history: RouterHistory
   private matcher: RouterMatcher
   private beforeGuards: NavigationGuard[] = []
   private afterGuards: PostNavigationGuard[] = []
@@ -91,7 +93,7 @@ export class Router {
         // accept current navigation
         this.currentRoute = {
           ...to,
-          ...matchedRoute,
+          ...matchedRoute
         }
         this.updateReactiveRoute()
         // TODO: refactor with a state getter
@@ -118,16 +120,17 @@ export class Router {
           this.push(error.to).catch(() => {})
         } else if (NavigationAborted.is(error)) {
           // 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)
-          }
+          // 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)
         }
@@ -141,14 +144,15 @@ export class Router {
   ) {
     if (typeof to === 'string')
       return this.resolveLocation(
-        this.history.utils.normalizeLocation(to),
+        // TODO: refactor and remove import
+        normalizeLocation(to),
         currentLocation
       )
     return this.resolveLocation({
       // TODO: refactor with url utils
       query: {},
       hash: '',
-      ...to,
+      ...to
     })
   }
 
@@ -166,21 +170,21 @@ export class Router {
       // target location normalized, used if we want to redirect again
       const normalizedLocation: RouteLocationNormalized = {
         ...matchedRoute.normalizedLocation,
-        fullPath: this.history.utils.stringifyURL({
+        fullPath: stringifyURL({
           path: matchedRoute.normalizedLocation.path,
           query: location.query,
-          hash: location.hash,
+          hash: location.hash
         }),
-        query: this.history.utils.normalizeQuery(location.query || {}),
+        query: normalizeQuery(location.query || {}),
         hash: location.hash,
         redirectedFrom,
-        meta: {},
+        meta: {}
       }
 
       if (typeof redirect === 'string') {
         // match the redirect instead
         return this.resolveLocation(
-          this.history.utils.normalizeLocation(redirect),
+          normalizeLocation(redirect),
           currentLocation,
           normalizedLocation
         )
@@ -189,7 +193,7 @@ export class Router {
 
         if (typeof newLocation === 'string') {
           return this.resolveLocation(
-            this.history.utils.normalizeLocation(newLocation),
+            normalizeLocation(newLocation),
             currentLocation,
             normalizedLocation
           )
@@ -202,8 +206,8 @@ export class Router {
         return this.resolveLocation(
           {
             ...newLocation,
-            query: this.history.utils.normalizeQuery(newLocation.query || {}),
-            hash: newLocation.hash || '',
+            query: normalizeQuery(newLocation.query || {}),
+            hash: newLocation.hash || ''
           },
           currentLocation,
           normalizedLocation
@@ -212,8 +216,8 @@ export class Router {
         return this.resolveLocation(
           {
             ...redirect,
-            query: this.history.utils.normalizeQuery(redirect.query || {}),
-            hash: redirect.hash || '',
+            query: normalizeQuery(redirect.query || {}),
+            hash: redirect.hash || ''
           },
           currentLocation,
           normalizedLocation
@@ -221,15 +225,15 @@ export class Router {
       }
     } else {
       // add the redirectedFrom field
-      const url = this.history.utils.normalizeLocation({
+      const url = normalizeLocation({
         path: matchedRoute.path,
         query: location.query,
-        hash: location.hash,
+        hash: location.hash
       })
       return {
         ...matchedRoute,
         ...url,
-        redirectedFrom,
+        redirectedFrom
       }
     }
   }
@@ -263,20 +267,20 @@ export class Router {
     let location: RouteLocationNormalized
     // TODO: refactor into matchLocation to return location and url
     if (typeof to === 'string' || ('path' in to && !('name' in to))) {
-      url = this.history.utils.normalizeLocation(to)
+      url = normalizeLocation(to)
       // TODO: should allow a non matching url to allow dynamic routing to work
       location = this.resolveLocation(url, this.currentRoute)
     } else {
       // named or relative route
-      const query = to.query ? this.history.utils.normalizeQuery(to.query) : {}
+      const query = to.query ? normalizeQuery(to.query) : {}
       const hash = to.hash || ''
       // we need to resolve first
       location = this.resolveLocation({ ...to, query, hash }, this.currentRoute)
       // intentionally drop current query and hash
-      url = this.history.utils.normalizeLocation({
+      url = normalizeLocation({
         query,
         hash,
-        ...location,
+        ...location
       })
     }