--- /dev/null
+{
+ "semi": false,
+ "trailingComma": "es5",
+ "singleQuote": true
+}
) {
return new Router({
history: new HTML5History(),
- ...options
+ ...options,
})
}
>()
const named = {
default: jest.fn(),
- other: jest.fn()
+ other: jest.fn(),
}
const nested = {
nestedAbs: jest.fn(),
nestedNested: jest.fn(),
nestedNestedFoo: jest.fn(),
- nestedNestedParam: jest.fn()
+ nestedNestedParam: jest.fn(),
}
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',
{
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() {
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()
// @ts-ignore
AbstractHistory,
plugin,
- BaseHistory,
+ createHistory,
} from '../src'
import { RouteComponent } from '../src/types'
import Vue from 'vue'
interface Window {
vm: Vue
// h: HTML5History
- h: BaseHistory
+ h: ReturnType<typeof createHistory>
r: Router
}
}
+const routerHistory = createHistory()
+window.h = routerHistory
+
const shared = {
cancel: false,
}
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 },
})
// 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)
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
)
})
-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',
// },
// } 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
})
run()
-
-
--- /dev/null
+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 || '',
+ }
+}
--- /dev/null
+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)
+ }
+ }
+}
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'
}
// 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
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'](),
})
}
}
import {
- BaseHistory,
- HistoryLocationNormalized,
- NavigationDirection,
-} from './history/base'
+ normalizeLocation,
+ RouterHistory,
+ stringifyURL,
+ normalizeQuery,
+ HistoryLocationNormalized
+} from './history/common'
import { RouterMatcher } from './matcher'
import {
RouteLocation,
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 {
}
export interface RouterOptions {
- history: BaseHistory
+ history: RouterHistory
routes: RouteRecord[]
// TODO: async version
scrollBehavior?: ScrollBehavior
// 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[] = []
// accept current navigation
this.currentRoute = {
...to,
- ...matchedRoute,
+ ...matchedRoute
}
this.updateReactiveRoute()
// TODO: refactor with a state getter
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)
}
) {
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
})
}
// 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
)
if (typeof newLocation === 'string') {
return this.resolveLocation(
- this.history.utils.normalizeLocation(newLocation),
+ normalizeLocation(newLocation),
currentLocation,
normalizedLocation
)
return this.resolveLocation(
{
...newLocation,
- query: this.history.utils.normalizeQuery(newLocation.query || {}),
- hash: newLocation.hash || '',
+ query: normalizeQuery(newLocation.query || {}),
+ hash: newLocation.hash || ''
},
currentLocation,
normalizedLocation
return this.resolveLocation(
{
...redirect,
- query: this.history.utils.normalizeQuery(redirect.query || {}),
- hash: redirect.hash || '',
+ query: normalizeQuery(redirect.query || {}),
+ hash: redirect.hash || ''
},
currentLocation,
normalizedLocation
}
} 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
}
}
}
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
})
}