From: Eduardo San Martin Morote Date: Tue, 16 Apr 2019 14:44:21 +0000 (+0200) Subject: refactor: properly handle query and hash in history X-Git-Tag: v4.0.0-alpha.0~444 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=9dd74fc0d1a4eefa2113b448006241d92422331b;p=thirdparty%2Fvuejs%2Frouter.git refactor: properly handle query and hash in history --- diff --git a/__tests__/html5.spec.js b/__tests__/html5.spec.js index c7e71689..48f73a54 100644 --- a/__tests__/html5.spec.js +++ b/__tests__/html5.spec.js @@ -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: '', + }) }) }) diff --git a/__tests__/url.spec.js b/__tests__/url.spec.js index a2305f42..60bf3bd7 100644 --- a/__tests__/url.spec.js +++ b/__tests__/url.spec.js @@ -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', () => { diff --git a/src/history/abstract.ts b/src/history/abstract.ts index 9e162018..ed709ed9 100644 --- a/src/history/abstract.ts +++ b/src/history/abstract.ts @@ -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 () => {} diff --git a/src/history/base.ts b/src/history/base.ts index 159876c5..ce84bcdc 100644 --- a/src/history/base.ts +++ b/src/history/base.ts @@ -1,19 +1,22 @@ -export type HistoryLocation = string +import * as utils from './utils' export type HistoryQuery = Record -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 { + // 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 {} // export type HistoryState = Record 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 diff --git a/src/history/html5.ts b/src/history/html5.ts index deabc096..6871484a 100644 --- a/src/history/html5.ts +++ b/src/history/html5.ts @@ -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 index 00000000..13a5aeb9 --- /dev/null +++ b/src/history/utils.ts @@ -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 || '', + } +} diff --git a/src/router.ts b/src/router.ts index afc85349..c3d30e38 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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) {} diff --git a/src/types/index.ts b/src/types/index.ts index b505137a..f1e4fb5e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { fullPath: string name: string | void - params: RouteParams - query: RouteQuery - hash: string } // interface PropsTransformer { diff --git a/src/utils/index.ts b/src/utils/index.ts index 58d59e1e..7da1385c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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 ''