From: Eduardo San Martin Morote Date: Tue, 8 Oct 2019 09:06:13 +0000 (+0200) Subject: refactor: createHistory wip X-Git-Tag: v4.0.0-alpha.0~214 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=270891ee4d5df989b47eb76d60fcc4861de75747;p=thirdparty%2Fvuejs%2Frouter.git refactor: createHistory wip --- diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..9d313aa4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "trailingComma": "es5", + "singleQuote": true +} diff --git a/__tests__/guards/component-beforeRouteEnter.spec.ts b/__tests__/guards/component-beforeRouteEnter.spec.ts index 8d521312..f2aaa52f 100644 --- a/__tests__/guards/component-beforeRouteEnter.spec.ts +++ b/__tests__/guards/component-beforeRouteEnter.spec.ts @@ -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: `
`, - beforeRouteEnter: spy + beforeRouteEnter: spy, } const [promise, resolve] = fakePromise() const router = createRouter({ - routes: [...routes, { path: '/async', component: () => promise }] + routes: [...routes, { path: '/async', component: () => promise }], }) const pushPromise = router[navigationMethod]('/async') expect(spy).not.toHaveBeenCalled() diff --git a/explorations/html5.ts b/explorations/html5.ts index 1e8849c6..13d2f814 100644 --- a/explorations/html5.ts +++ b/explorations/html5.ts @@ -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 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 index 00000000..a5c4ee82 --- /dev/null +++ b/src/history/common.ts @@ -0,0 +1,255 @@ +import { ListenerRemover } from '../types' + +type HistoryQuery = Record +// 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 + +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 { + // 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 {} + +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 index 00000000..747b005e --- /dev/null +++ b/src/history/html5.2.ts @@ -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) + } + } +} diff --git a/src/index.ts b/src/index.ts index 66033ec7..519bb126 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ) { - 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'](), }) } } diff --git a/src/router.ts b/src/router.ts index 926356df..b8caabcc 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 }) }