]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: properly handle query and hash in history
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 16 Apr 2019 14:44:21 +0000 (16:44 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 16 Apr 2019 14:44:21 +0000 (16:44 +0200)
__tests__/html5.spec.js
__tests__/url.spec.js
src/history/abstract.ts
src/history/base.ts
src/history/html5.ts
src/history/utils.ts [new file with mode: 0644]
src/router.ts
src/types/index.ts
src/utils/index.ts

index c7e7168951588b388f56cbbdec3fc5fdd54e5ba3..48f73a540d48bf3b1ef585848b17b4a6a480ba75 100644 (file)
@@ -22,6 +22,11 @@ describe('History HTMl5', () => {
 
   it('can be instantiated', () => {
     const history = new HTML5History()
-    expect(history.location).toBe('/')
+    expect(history.location).toEqual({
+      fullPath: '/',
+      path: '/',
+      query: {},
+      hash: '',
+    })
   })
 })
index a2305f42508eca0dfab5d70cc27d05575d99ba4b..60bf3bd788d3ba9eab70a171ac81345a3da49e57 100644 (file)
@@ -1,9 +1,7 @@
 // @ts-check
 require('./helper')
 const expect = require('expect')
-const { BaseHistory } = require('../src/history/base')
-
-const parseURL = BaseHistory.prototype.parseURL
+const { parseURL } = require('../src/history/utils')
 
 describe('URL parsing', () => {
   it('works with no query no hash', () => {
index 9e162018a94d504cf62bf38dfee19ce73386ad2e..ed709ed9be49cae8ddf2a7519c35647dcc14b491 100644 (file)
@@ -1,6 +1,6 @@
 import consola from 'consola'
-import { BaseHistory } from './base'
-import { HistoryLocation, NavigationCallback, HistoryState } from './base'
+import { BaseHistory, HistoryLocationNormalized } from './base'
+import { NavigationCallback, HistoryState } from './base'
 
 const cs = consola.withTag('abstract')
 
@@ -16,9 +16,9 @@ export class AbstractHistory extends BaseHistory {
   // TODO: is this necessary
   ensureLocation() {}
 
-  replace(to: HistoryLocation) {}
+  replace(to: HistoryLocationNormalized) {}
 
-  push(to: HistoryLocation, data?: HistoryState) {}
+  push(to: HistoryLocationNormalized, data?: HistoryState) {}
 
   listen(callback: NavigationCallback) {
     return () => {}
index 159876c5769a818b11923346ba9a86af6b4a19d0..ce84bcdca8c91e148de697aca369d432a75a1e1b 100644 (file)
@@ -1,19 +1,22 @@
-export type HistoryLocation = string
+import * as utils from './utils'
 
 export type HistoryQuery = Record<string, string | string[]>
-export interface HistoryURL {
-  // full path (like href)
-  fullPath: string
+
+export interface HistoryLocation {
   // pathname section
   path: string
   // search string parsed
-  query: HistoryQuery
+  query?: HistoryQuery
   // hash with the #
-  hash: string
+  hash?: string
+}
+export interface HistoryLocationNormalized extends Required<HistoryLocation> {
+  // full path (like href)
+  fullPath: string
 }
 
 // pushState clones the state passed and do not accept everything
-// it doesn't accept symbols, nor functions. It also ignores Symbols as keys
+// it doesn't accept symbols, nor functions as values. It also ignores Symbols as keys
 type HistoryStateValue =
   | string
   | number
@@ -28,7 +31,12 @@ export interface HistoryState {
 interface HistoryStateArray extends Array<HistoryStateValue> {}
 // export type HistoryState = Record<string | number, string | number | boolean | undefined | null |
 
-export const START: HistoryLocation = '/'
+export const START: HistoryLocationNormalized = {
+  fullPath: '/',
+  path: '/',
+  query: {},
+  hash: '',
+}
 
 export enum NavigationType {
   // NOTE: is it better to have strings?
@@ -38,20 +46,19 @@ export enum NavigationType {
 
 export interface NavigationCallback {
   (
-    to: HistoryLocation,
-    from: HistoryLocation,
+    to: HistoryLocationNormalized,
+    from: HistoryLocationNormalized,
     info: { type: NavigationType }
   ): void
 }
 
 export type RemoveListener = () => void
 
-const PERCENT_RE = /%/g
-
 export abstract class BaseHistory {
   // previousState: object
-  location: HistoryLocation = START
+  location: HistoryLocationNormalized = START
   base: string = ''
+  utils = utils
 
   /**
    * Sync source with a different location.
@@ -76,104 +83,6 @@ export abstract class BaseHistory {
    */
   abstract listen(callback: NavigationCallback): RemoveListener
 
-  /**
-   * Transforms a URL into an object
-   * @param location location to normalize
-   * @param currentLocation current location, to reuse params and location
-   */
-  parseURL(location: string): HistoryURL {
-    let path = '',
-      query: HistoryURL['query'] = {},
-      searchString = '',
-      hash = ''
-
-    // Could use URL and URLSearchParams but IE 11 doesn't support it
-    // TODO: move this utility to base.ts so it can be used by any history implementation
-    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: properly do this in a util function
-      query = searchString.split('&').reduce((query, entry) => {
-        const [key, value] = entry.split('=')
-        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
-      }, query)
-    }
-
-    if (hashPos > -1) {
-      path = path || location.slice(0, hashPos)
-      hash = location.slice(hashPos, location.length)
-    }
-
-    path = path || location
-
-    return {
-      fullPath: location,
-      path,
-      query,
-      hash,
-    }
-  }
-
-  /**
-   * Stringify a URL object
-   * @param location
-   */
-  stringifyURL(location: {
-    path: string
-    query?: HistoryQuery
-    hash?: string
-  }): string {
-    let url = location.path
-    let query = '?'
-    // TODO: util function?
-    for (const key in location.query) {
-      if (query.length > 1) query += '&'
-      // TODO: handle array
-      const value = location.query[key]
-      if (Array.isArray(value)) {
-        query += `${key}=${value[0]}`
-        for (let i = 1; i < value.length; i++) {
-          query += `&${key}=${value[i]}`
-        }
-      } else {
-        query += `${key}=${location.query[key]}`
-      }
-    }
-
-    if (query.length > 1) url += query
-
-    return url + (location.hash || '')
-  }
-
-  /**
-   * Prepare a URI string to be passed to pushState
-   * @param uri
-   */
-  prepareURI(uri: string) {
-    // encode the % symbol so it also works on IE
-    return uri.replace(PERCENT_RE, '%25')
-  }
-
-  // use regular decodeURI
-  decodeURI = decodeURI
-
   /**
    * ensure the current location matches the external source
    * For example, in HTML5 and hash history, that would be
index deabc096da3bf2ffcefa4bfe34b487cd078e0e40..6871484a9f55eb318e26ffc542b4d1c1cf641fc6 100644 (file)
@@ -1,28 +1,23 @@
 import consola from 'consola'
-import { BaseHistory } from './base'
-import {
-  HistoryLocation,
-  NavigationCallback,
-  HistoryState,
-  NavigationType,
-} from './base'
+import { BaseHistory, HistoryLocationNormalized, HistoryLocation } from './base'
+import { NavigationCallback, HistoryState, NavigationType } from './base'
 
 const cs = consola.withTag('html5')
 
 type PopStateListener = (this: Window, ev: PopStateEvent) => any
 
 interface StateEntry {
-  back: HistoryLocation | null
-  current: HistoryLocation
-  forward: HistoryLocation | null
+  back: HistoryLocationNormalized | null
+  current: HistoryLocationNormalized
+  forward: HistoryLocationNormalized | null
   replaced: boolean
 }
 
 // TODO: pretty useless right now except for typing
 function buildState(
-  back: HistoryLocation | null,
-  current: HistoryLocation,
-  forward: HistoryLocation | null,
+  back: HistoryLocationNormalized | null,
+  current: HistoryLocationNormalized,
+  forward: HistoryLocationNormalized | null,
   replaced: boolean = false
 ): StateEntry {
   return {
@@ -35,7 +30,6 @@ function buildState(
 
 export class HTML5History extends BaseHistory {
   private history = window.history
-  location: HistoryLocation
   private _popStateHandler: PopStateListener
   private _listeners: NavigationCallback[] = []
   private _teardowns: Array<() => void> = []
@@ -44,7 +38,7 @@ export class HTML5History extends BaseHistory {
     super()
     const to = buildFullPath()
     // cs.log('created', to)
-    this.history.replaceState(buildState(null, to, null), '', to)
+    this.history.replaceState(buildState(null, to, null), '', to.fullPath)
     this.location = to
     this._popStateHandler = this.setupPopStateListener()
   }
@@ -53,26 +47,27 @@ export class HTML5History extends BaseHistory {
   ensureLocation() {}
 
   replace(to: HistoryLocation) {
-    // TODO: standarize URL
-    if (to === this.location) return
-    cs.info('replace', this.location, to)
+    const normalized = this.utils.normalizeLocation(to)
+    if (normalized.fullPath === this.location.fullPath) return
+    cs.info('replace', this.location, normalized)
     this.history.replaceState(
       // TODO: this should be user's responsibility
       // _replacedState: this.history.state || null,
-      buildState(this.history.state.back, to, null, true),
+      buildState(this.history.state.back, normalized, null, true),
       '',
-      to
+      normalized.fullPath
     )
-    this.location = to
+    this.location = normalized
   }
 
   push(to: HistoryLocation, data?: HistoryState) {
     // replace current entry state to add the forward value
+    const normalized = this.utils.normalizeLocation(to)
     this.history.replaceState(
       buildState(
         this.history.state.back,
         this.history.state.current,
-        to,
+        normalized,
         this.history.state.replaced
       ),
       ''
@@ -81,12 +76,12 @@ export class HTML5History extends BaseHistory {
     // NEW NOTE: I think it shouldn't be history responsibility to check that
     // if (to === this.location) return
     const state = {
-      ...buildState(this.location, to, null),
+      ...buildState(this.location, normalized, null),
       ...data,
     }
     cs.info('push', this.location, '->', to, 'with state', state)
-    this.history.pushState(state, '', to)
-    this.location = to
+    this.history.pushState(state, '', normalized.fullPath)
+    this.location = normalized
   }
 
   listen(callback: NavigationCallback) {
@@ -143,5 +138,12 @@ export class HTML5History extends BaseHistory {
   }
 }
 
-const buildFullPath = () =>
-  window.location.pathname + window.location.search + window.location.hash
+const buildFullPath = () => {
+  const { location } = window
+  return {
+    fullPath: location.pathname + location.search + location.hash,
+    path: location.pathname,
+    query: {}, // TODO: parseQuery
+    hash: location.hash,
+  }
+}
diff --git a/src/history/utils.ts b/src/history/utils.ts
new file mode 100644 (file)
index 0000000..13a5aeb
--- /dev/null
@@ -0,0 +1,139 @@
+import {
+  HistoryLocationNormalized,
+  HistoryQuery,
+  HistoryLocation,
+} from './base'
+
+const PERCENT_RE = /%/g
+
+/**
+ * Transforms a URL into an object
+ * @param location location to normalize
+ * @param currentLocation current location, to reuse params and 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
+    )
+
+    query = parseQuery(searchString)
+  }
+
+  if (hashPos > -1) {
+    path = path || location.slice(0, hashPos)
+    hash = location.slice(hashPos, location.length)
+  }
+
+  path = path || location
+
+  return {
+    fullPath: location,
+    path,
+    query,
+    hash,
+  }
+}
+
+/**
+ * Transform a queryString into a query object. Accept both, a version with the leading `?` and without
+ * Should work as URLSearchParams
+ * @param search
+ */
+export function parseQuery(search: string): HistoryQuery {
+  // TODO: optimize by using a for loop
+  const hasLeadingIM = search[0] === '?'
+  return (hasLeadingIM ? search.slice(1) : search).split('&').reduce(
+    (query: HistoryQuery, entry: string) => {
+      const [key, value] = entry.split('=')
+      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
+    },
+    {} as HistoryQuery
+  )
+}
+
+/**
+ * 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.length && '?' + query) + (location.hash || '')
+}
+
+/**
+ * Stringify an object query. Works like URLSearchParams. Doesn't prepend a `?`
+ * @param query
+ */
+export function stringifyQuery(query: HistoryQuery): string {
+  let search = ''
+  // TODO: util function?
+  for (const key in query) {
+    if (search.length > 1) search += '&'
+    // TODO: handle array
+    const value = query[key]
+    if (Array.isArray(value)) {
+      search += `${key}=${value[0]}`
+      for (let i = 1; i < value.length; i++) {
+        search += `&${key}=${value[i]}`
+      }
+    } else {
+      search += `${key}=${query[key]}`
+    }
+  }
+
+  return search
+}
+
+/**
+ * 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
+export const decodeURI = global.decodeURI
+
+/**
+ * Normalize a History location into an object that looks like
+ * the one at window.location
+ * @param location
+ */
+export function normalizeLocation(
+  location: string | HistoryLocation
+): HistoryLocationNormalized {
+  if (typeof location === 'string') return parseURL(location)
+  else
+    return {
+      fullPath: stringifyURL(location),
+      path: location.path,
+      query: location.query || {},
+      hash: location.hash || '',
+    }
+}
index afc85349ba96e3318bb381992ea68ba4a4c69ffa..c3d30e38e2313af17526f9724847ee68a248de71 100644 (file)
@@ -1,11 +1,11 @@
-import { BaseHistory } from './history/base'
+import { BaseHistory, HistoryLocationNormalized } from './history/base'
 import { RouterMatcher } from './matcher'
 import {
   RouteLocation,
   RouteRecord,
   START_LOCATION_NORMALIZED,
   RouteLocationNormalized,
-  RouteQuery,
+  MatcherLocationNormalized,
 } from './types/index'
 
 interface RouterOptions {
@@ -26,9 +26,8 @@ export class Router {
 
     this.history.listen((to, from, info) => {
       // TODO: check navigation guards
-      const url = this.history.parseURL(to)
-      const matchedRoute = this.matcher.resolve(url, this.currentRoute)
-      console.log({ url, matchedRoute })
+      const matchedRoute = this.matcher.resolve(to, this.currentRoute)
+      console.log({ to, matchedRoute })
       // TODO: navigate
     })
   }
@@ -38,42 +37,27 @@ export class Router {
    * @param to Where to go
    */
   push(to: RouteLocation) {
-    // TODO: resolve URL
-    let url, fullPath: string, query: RouteQuery, hash: string
-    if (typeof to === 'string') {
-      url = this.history.parseURL(to)
-      fullPath = url.fullPath
-      query = url.query
-      hash = url.hash
-    } else if ('path' in to) {
-      fullPath = this.history.stringifyURL(to)
-      query = to.query || {}
-      hash = to.hash || ''
-      url = to
-    } else if ('name' in to) {
-      // we need to resolve first
-      url = to
+    let url: HistoryLocationNormalized
+    let location: MatcherLocationNormalized
+    if (typeof to === 'string' || 'path' in to) {
+      url = this.history.utils.normalizeLocation(to)
+      location = this.matcher.resolve(url, this.currentRoute)
     } else {
+      // named or relative route
       // we need to resolve first
-      url = to
+      location = this.matcher.resolve(to, this.currentRoute)
+      url = this.history.utils.normalizeLocation(location)
     }
-    console.log('going to', to)
-    const location = this.matcher.resolve(url, this.currentRoute)
 
     console.log(location)
-    // @ts-ignore
-    console.log({ fullPath, query, hash })
     console.log('---')
     // TODO: call hooks, guards
     // TODO: navigate
     // this.history.push(location.fullPath)
-    // this.currentRoute = {
-    //   ...url,
-    //   ...location,
-    //   fullPath,
-    //   query,
-    //   hash,
-    // }
+    this.currentRoute = {
+      ...url,
+      ...location,
+    }
   }
 
   getRouteRecord(location: RouteLocation) {}
index b505137a92852541ba6faed61999093dab91a044..f1e4fb5e60257e1f22ea9bf0bebb6da719d67af7 100644 (file)
@@ -35,13 +35,10 @@ export type MatcherLocation =
   | LocationAsRelative
 
 // exposed to the user in a very consistant way
-export interface RouteLocationNormalized {
-  path: string
+export interface RouteLocationNormalized
+  extends Required<RouteQueryAndHash & LocationAsRelative & LocationAsPath> {
   fullPath: string
   name: string | void
-  params: RouteParams
-  query: RouteQuery
-  hash: string
 }
 
 // interface PropsTransformer {
index 58d59e1e73bc4bded57c1f280d6947fdcbf9e6fb..7da1385cc6ecef5adf54937031de61eab98eb1fb 100644 (file)
@@ -1,5 +1,7 @@
 import { RouteQuery } from '../types'
 
+// TODO: merge with existing function from history/base.ts and more to
+// history utils
 export function stringifyQuery(query: RouteQuery | void): string {
   if (!query) return ''