From: Eduardo San Martin Morote Date: Wed, 26 Feb 2020 16:10:00 +0000 (+0100) Subject: feat: add dynamic routing at router level X-Git-Tag: v4.0.0-alpha.1~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=a7943c64383bced7ff90ae92c0498827acdb71f6;p=thirdparty%2Fvuejs%2Frouter.git feat: add dynamic routing at router level --- diff --git a/__tests__/router.spec.ts b/__tests__/router.spec.ts index 77f1d010..06424f1f 100644 --- a/__tests__/router.spec.ts +++ b/__tests__/router.spec.ts @@ -333,4 +333,121 @@ describe('Router', () => { }) // it('redirects with route record redirect') + + describe('Dynamic Routing', () => { + it('resolves new added routes', async () => { + const { router } = await newRouter() + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + router.addRoute({ + path: '/new-route', + component: components.Foo, + name: 'new route', + }) + expect(router.resolve('/new-route')).toMatchObject({ + name: 'new route', + }) + }) + + it('can redirect to children in the middle of navigation', async () => { + const { router } = await newRouter() + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + let removeRoute: (() => void) | undefined + router.addRoute({ + path: '/dynamic', + component: components.Nested, + name: 'dynamic parent', + options: { end: false, strict: true }, + beforeEnter(to, from, next) { + if (!removeRoute) { + removeRoute = router.addRoute('dynamic parent', { + path: 'child', + name: 'dynamic child', + component: components.Foo, + }) + next(to.fullPath) + } else next() + }, + }) + + router.push('/dynamic/child').catch(() => {}) + await tick() + expect(router.currentRoute.value).toMatchObject({ + name: 'dynamic child', + }) + }) + + it('can reroute when adding a new route', async () => { + const { router } = await newRouter() + await router.push('/p/p') + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.replace(router.currentRoute.value.fullPath) + expect(router.currentRoute.value).toMatchObject({ + name: 'pp', + }) + }) + + it('stops resolving removed routes', async () => { + const { router } = await newRouter() + // regular route + router.removeRoute('Foo') + expect(router.resolve('/foo')).toMatchObject({ + name: undefined, + matched: [], + }) + // dynamic route + const removeRoute = router.addRoute({ + path: '/new-route', + component: components.Foo, + name: 'new route', + }) + removeRoute() + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + }) + + it('can reroute when removing route', async () => { + const { router } = await newRouter() + router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.push('/p/p') + router.removeRoute('pp') + await router.replace(router.currentRoute.value.fullPath) + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + }) + + it('can reroute when removing route through returned function', async () => { + const { router } = await newRouter() + const remove = router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.push('/p/p') + remove() + await router.push('/p/p') + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + }) + }) }) diff --git a/playground/router.ts b/playground/router.ts index 116a70e1..78a9e082 100644 --- a/playground/router.ts +++ b/playground/router.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from '../src' import Home from './views/Home.vue' import Nested from './views/Nested.vue' +import Dynamic from './views/Dynamic.vue' import User from './views/User.vue' import NotFound from './views/NotFound.vue' import component from './views/Generic.vue' @@ -9,6 +10,7 @@ import GuardedWithLeave from './views/GuardedWithLeave.vue' import ComponentWithData from './views/ComponentWithData.vue' import { globalState } from './store' import { scrollWaiter } from './scrollWaiter' +let removeRoute: (() => void) | undefined // const hist = new HTML5History() // const hist = new HashHistory() @@ -57,6 +59,21 @@ export const router = createRouter({ }, ], }, + { + path: '/dynamic', + name: 'dynamic', + component: Nested, + options: { end: false, strict: true }, + beforeEnter(to, from, next) { + if (!removeRoute) { + removeRoute = router.addRoute('dynamic', { + path: 'child', + component: Dynamic, + }) + next(to.fullPath) + } else next() + }, + }, ], async scrollBehavior(to, from, savedPosition) { await scrollWaiter.wait() diff --git a/playground/views/Dynamic.vue b/playground/views/Dynamic.vue new file mode 100644 index 00000000..b63a9426 --- /dev/null +++ b/playground/views/Dynamic.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/matcher/index.ts b/src/matcher/index.ts index b3f215a7..0269bc77 100644 --- a/src/matcher/index.ts +++ b/src/matcher/index.ts @@ -22,8 +22,7 @@ interface RouterMatcher { (matcher: RouteRecordMatcher): void (name: Required['name']): void } - // TODO: - // getRoutes: () => RouteRecordMatcher[] + getRoutes: () => RouteRecordMatcher[] getRecordMatcher: ( name: Required['name'] ) => RouteRecordMatcher | undefined @@ -45,6 +44,7 @@ export function createRouterMatcher( return matcherMap.get(name) } + // TODO: add routes to children of parent function addRoute( record: Readonly, parent?: RouteRecordMatcher @@ -116,6 +116,10 @@ export function createRouterMatcher( } } + function getRoutes() { + return matchers + } + function insertMatcher(matcher: RouteRecordMatcher) { let i = 0 // console.log('i is', { i }) @@ -199,7 +203,7 @@ export function createRouterMatcher( // add initial routes routes.forEach(route => addRoute(route)) - return { addRoute, resolve, removeRoute, getRecordMatcher } + return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher } } /** diff --git a/src/matcher/path-parser-ranker.ts b/src/matcher/path-parser-ranker.ts index 93df0ff0..3ff0e324 100644 --- a/src/matcher/path-parser-ranker.ts +++ b/src/matcher/path-parser-ranker.ts @@ -179,7 +179,7 @@ export function tokensToParser( } // only apply the strict bonus to the last score - if (options.strict) { + if (options.strict && options.end) { const i = score.length - 1 score[i][score[i].length - 1] += PathScore.BonusStrict } @@ -188,6 +188,8 @@ export function tokensToParser( if (!options.strict) pattern += '/?' if (options.end) pattern += '$' + // allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_somethingelse + else if (options.strict) pattern += '(?:/|$)' const re = new RegExp(pattern, options.sensitive ? '' : 'i') diff --git a/src/router.ts b/src/router.ts index 54cbf6a4..e7451b6c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -29,8 +29,13 @@ import { } from './utils' import { useCallbacks } from './utils/callbacks' import { encodeParam, decode } from './utils/encoding' -import { normalizeQuery, parseQuery, stringifyQuery } from './utils/query' -import { ref, Ref, markNonReactive, nextTick, App } from 'vue' +import { + normalizeQuery, + parseQuery, + stringifyQuery, + LocationQueryValue, +} from './utils/query' +import { ref, Ref, markNonReactive, nextTick, App, warn } from 'vue' import { RouteRecordNormalized } from './matcher/types' import { Link } from './components/Link' import { View } from './components/View' @@ -58,6 +63,11 @@ export interface Router { history: RouterHistory currentRoute: Ref> + addRoute(parentName: string, route: RouteRecord): () => void + addRoute(route: RouteRecord): () => void + removeRoute(name: string): void + getRoutes(): RouteRecordNormalized[] + resolve(to: RouteLocation): RouteLocationNormalized createHref(to: RouteLocationNormalized): string push(to: RouteLocation): Promise @@ -100,6 +110,33 @@ export function createRouter({ const encodeParams = applyToParams.bind(null, encodeParam) const decodeParams = applyToParams.bind(null, decode) + function addRoute(parentOrRoute: string | RouteRecord, route?: RouteRecord) { + let parent: Parameters[1] | undefined + let record: RouteRecord + if (typeof parentOrRoute === 'string') { + parent = matcher.getRecordMatcher(parentOrRoute) + record = route! + } else { + record = parentOrRoute + } + + return matcher.addRoute(record, parent) + } + + function removeRoute(name: string) { + let recordMatcher = matcher.getRecordMatcher(name) + if (recordMatcher) { + matcher.removeRoute(recordMatcher) + } else if (__DEV__) { + // TODO: adapt if we allow Symbol as a name + warn(`Cannot remove non-existant route "${name}"`) + } + } + + function getRoutes(): RouteRecordNormalized[] { + return matcher.getRoutes().map(routeMatcher => routeMatcher.record) + } + function resolve( location: RouteLocation, currentLocation?: RouteLocationNormalized @@ -161,14 +198,12 @@ export function createRouter({ ): Promise { const toLocation: RouteLocationNormalized = (pendingLocation = resolve(to)) const from: RouteLocationNormalized = currentRoute.value + // @ts-ignore: no need to check the string as force do not exist on a string + const force: boolean | undefined = to.force // TODO: should we throw an error as the navigation was aborted // TODO: needs a proper check because order in query could be different - if ( - from !== START_LOCATION_NORMALIZED && - from.fullPath === toLocation.fullPath - ) - return from + if (!force && isSameLocation(from, toLocation)) return from toLocation.redirectedFrom = redirectedFrom @@ -427,12 +462,19 @@ export function createRouter({ const router: Router = { currentRoute, + + addRoute, + removeRoute, + getRoutes, + push, replace, resolve, + beforeEach: beforeGuards.add, afterEach: afterGuards.add, createHref, + onError: errorHandlers.add, isReady, @@ -497,3 +539,44 @@ function extractChangingRecords( return [leavingRecords, updatingRecords, enteringRecords] } + +function isSameLocation( + a: RouteLocationNormalized, + b: RouteLocationNormalized +): boolean { + return ( + a.name === b.name && + a.path === b.path && + a.hash === b.hash && + isSameLocationQuery(a.query, b.query) + ) +} + +function isSameLocationQuery( + a: RouteLocationNormalized['query'], + b: RouteLocationNormalized['query'] +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + let i = 0 + let key: string + while (i < aKeys.length) { + key = aKeys[i] + if (key !== bKeys[i]) return false + if (!isSameLocationQueryValue(a[key], b[key])) return false + i++ + } + + return true +} + +function isSameLocationQueryValue( + a: LocationQueryValue | LocationQueryValue[], + b: LocationQueryValue | LocationQueryValue[] +): boolean { + if (typeof a !== typeof b) return false + if (Array.isArray(a)) + return a.every((value, i) => value === (b as LocationQueryValue[])[i]) + return a === b +} diff --git a/src/types/index.ts b/src/types/index.ts index 00ab7e9a..e231633a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,7 +44,14 @@ export interface LocationAsRelative { } export interface RouteLocationOptions { + /** + * Replace the entry in the history instead of pushing a new entry + */ replace?: boolean + /** + * Triggers the navigation even if the location is the same as the current one + */ + force?: boolean } // User level location diff --git a/src/utils/query.ts b/src/utils/query.ts index 0830707e..498ea7d0 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -1,6 +1,6 @@ import { decode, encodeQueryProperty } from '../utils/encoding' -type LocationQueryValue = string | null +export type LocationQueryValue = string | null type LocationQueryValueRaw = LocationQueryValue | number | undefined export type LocationQuery = Record< string,