resolved.path = location.path
}
+ // use one single record
+ if (!('matched' in resolved)) resolved.matched = [record]
+
// allows not passing params
if ('params' in location) {
resolved.params = resolved.params || location.params
describe('LocationAsRelative', () => {
it('matches with nothing', () => {
+ const record = { path: '/home', name: 'Home', component }
assertRecordMatch(
- { path: '/home', name: 'Home', component },
+ record,
{},
{ name: 'Home', path: '/home' },
- { name: 'Home', params: {}, path: '/home' }
+ { name: 'Home', params: {}, path: '/home', matched: [record] }
)
})
it('replace params even with no name', () => {
+ const record = { path: '/users/:id/m/:role', component }
assertRecordMatch(
- { path: '/users/:id/m/:role', component },
+ record,
{ params: { id: 'posva', role: 'admin' } },
{ name: undefined, path: '/users/posva/m/admin' },
{
path: '/users/ed/m/user',
name: undefined,
params: { id: 'ed', role: 'user' },
+ matched: [record],
}
)
})
it('replace params', () => {
+ const record = {
+ path: '/users/:id/m/:role',
+ name: 'UserEdit',
+ component,
+ }
assertRecordMatch(
- { path: '/users/:id/m/:role', name: 'UserEdit', component },
+ record,
{ params: { id: 'posva', role: 'admin' } },
{ name: 'UserEdit', path: '/users/posva/m/admin' },
{
path: '/users/ed/m/user',
name: 'UserEdit',
params: { id: 'ed', role: 'user' },
+ matched: [],
}
)
})
it('keep params if not provided', () => {
+ const record = {
+ path: '/users/:id/m/:role',
+ name: 'UserEdit',
+ component,
+ }
assertRecordMatch(
- { path: '/users/:id/m/:role', name: 'UserEdit', component },
+ record,
{},
{
name: 'UserEdit',
path: '/users/ed/m/user',
name: 'UserEdit',
params: { id: 'ed', role: 'user' },
+ matched: [record],
}
)
})
it('keep params if not provided even with no name', () => {
+ const record = { path: '/users/:id/m/:role', component }
assertRecordMatch(
- { path: '/users/:id/m/:role', component },
+ record,
{},
{
name: undefined,
path: '/users/ed/m/user',
name: undefined,
params: { id: 'ed', role: 'user' },
+ matched: [record],
}
)
})
it('throws if the current named route does not exists', () => {
- expect(
- assertErrorMatch(
- { path: '/', component },
- {},
- { name: 'home', params: {}, path: '/' }
- )
- ).toMatchInlineSnapshot(
+ const record = { path: '/', component }
+ const start = { name: 'home', params: {}, path: '/', matched: [record] }
+ // the property should be non enumerable
+ Object.defineProperty(start, 'matched', { enumerable: false })
+ expect(assertErrorMatch(record, {}, start)).toMatchInlineSnapshot(
`[Error: No match for {"name":"home","params":{},"path":"/"}]`
)
})
--- /dev/null
+// @ts-check
+require('./helper')
+const expect = require('expect')
+const { HTML5History } = require('../src/history/html5')
+const { Router } = require('../src/router')
+const { JSDOM } = require('jsdom')
+const fakePromise = require('faked-promise')
+
+const tick = () => new Promise(resolve => process.nextTick(resolve))
+
+/**
+ * @param {Partial<import('../src/router').RouterOptions> & { routes: import('../src/types').RouteRecord[]}} options
+ */
+function createRouter(options) {
+ return new Router({
+ history: new HTML5History(),
+ ...options,
+ })
+}
+
+const Home = { template: `<div>Home</div>` }
+const Foo = { template: `<div>Foo</div>` }
+
+/** @type {import('../src/types').RouteRecord[]} */
+const routes = [
+ { path: '/', component: Home },
+ { path: '/foo', component: Foo },
+]
+
+describe('navigation guards', () => {
+ beforeAll(() => {
+ // TODO: move to utils for tests that need DOM
+ const dom = new JSDOM(
+ `<!DOCTYPE html><html><head></head><body></body></html>`,
+ {
+ url: 'https://example.org/',
+ referrer: 'https://example.com/',
+ contentType: 'text/html',
+ }
+ )
+
+ // @ts-ignore
+ global.window = dom.window
+ })
+
+ it('calls beforeEnter guards on push', async () => {
+ const spy = jest.fn()
+ const router = createRouter({
+ routes: [
+ ...routes,
+ {
+ path: '/guard/:n',
+ component: Foo,
+ beforeEnter: spy,
+ },
+ ],
+ })
+ spy.mockImplementationOnce((to, from, next) => {
+ if (to.params.n !== 'valid') return next(false)
+ next()
+ })
+ await router.push('/guard/valid')
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it.skip('calls beforeEnter guards on replace', () => {})
+
+ it('waits before navigating', async () => {
+ const [promise, resolve] = fakePromise()
+ const router = createRouter({ routes })
+ router.beforeEach(async (to, from, next) => {
+ await promise
+ next()
+ })
+ const p = router.push('/foo')
+ expect(router.currentRoute.fullPath).toBe('/')
+ resolve()
+ await p
+ expect(router.currentRoute.fullPath).toBe('/foo')
+ })
+
+ it('waits in the right order', async () => {
+ const [p1, r1] = fakePromise()
+ const [p2, r2] = fakePromise()
+ const router = createRouter({ routes })
+ const guard1 = jest.fn(async (to, from, next) => {
+ await p1
+ next()
+ })
+ router.beforeEach(guard1)
+ const guard2 = jest.fn(async (to, from, next) => {
+ await p2
+ next()
+ })
+ router.beforeEach(guard2)
+ let navigation = router.push('/foo')
+ expect(router.currentRoute.fullPath).toBe('/')
+ expect(guard1).toHaveBeenCalled()
+ expect(guard2).not.toHaveBeenCalled()
+ r1()
+ // wait until the guard is called
+ await tick()
+ await tick()
+ expect(guard2).toHaveBeenCalled()
+ r2()
+ expect(router.currentRoute.fullPath).toBe('/')
+ await navigation
+ expect(guard2).toHaveBeenCalled()
+ expect(router.currentRoute.fullPath).toBe('/foo')
+ })
+})
{ path: '/', component },
{ path: '/users/:id', name: 'user', component },
{ path: '/multiple/:a/:b', name: 'user', component },
+ {
+ path: '/with-guard/:n',
+ name: 'guarded',
+ component,
+ beforeEnter: (to, from, next) => {
+ if (to.params.n !== 'valid') next(false)
+ next()
+ },
+ },
// { path: /^\/about\/?$/, component },
],
})
const cs = consola.withTag('html5')
+if (process.env.NODE_ENV === 'test') cs.mockTypes(() => jest.fn())
+
type PopStateListener = (this: Window, ev: PopStateEvent) => any
interface StateEntry {
matcher = this.matchers.find(m => m.re.test(location.path))
if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+ // TODO: build up the array with children based on current location
+ const matched = [matcher.record]
const params: RouteParams = {}
const result = matcher.re.exec(location.path)
/// no need to resolve the path with the matcher as it was provided
path: location.path,
params,
+ matched,
}
}
matcher = this.matchers.find(m => m.record.name === location.name)
if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+ // TODO: build up the array with children based on current location
+ const matched = [matcher.record]
// TODO: try catch for resolve -> missing params
name: location.name,
path: matcher.resolve(location.params),
params: location.params || {}, // TODO: normalize params
+ matched,
}
}
}
if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+ // TODO: build up the array with children based on current location
+ const matched = [matcher.record]
let params = location.params ? location.params : currentLocation.params
name: currentLocation.name,
path: matcher.resolve(params),
params,
+ matched,
}
}
}
): Promise<TODO> {
// TODO: Will probably need to be some kind of queue in the future that allows to remove
// elements and other stuff
- const guards: Array<() => Promise<any>> = []
+ let guards: Array<() => Promise<any>> = []
+ // check global guards first
for (const guard of this.beforeGuards) {
- guards.push(
- () =>
- new Promise((resolve, reject) => {
- const next: NavigationGuardCallback = (valid?: boolean) => {
- // TODO: better error
- if (valid === false) reject(new Error('Aborted'))
- else resolve()
- }
-
- guard(to, from, next)
- })
- )
+ guards.push(guardToPromiseFn(guard, to, from))
}
// console.log('Guarding against', guards.length, 'guards')
for (const guard of guards) {
await guard()
}
+
+ // check the route beforeEnter
+ // TODO: check children. Should we also check reused routes guards
+ guards = []
+ for (const record of to.matched) {
+ if (record.beforeEnter)
+ guards.push(guardToPromiseFn(record.beforeEnter, to, from))
+ }
+
+ // run the queue of guards
+ for (const guard of guards) {
+ await guard()
+ }
}
getRouteRecord(location: RouteLocation) {}
}
}
}
+
+function guardToPromiseFn(
+ guard: NavigationGuard,
+ to: RouteLocationNormalized,
+ from: RouteLocationNormalized
+): () => Promise<void> {
+ return () =>
+ new Promise((resolve, reject) => {
+ const next: NavigationGuardCallback = (valid?: boolean) => {
+ // TODO: better error
+ if (valid === false) reject(new Error('Aborted'))
+ else resolve()
+ }
+
+ guard(to, from, next)
+ })
+}
query: HistoryQuery // the normalized version cannot have numbers
// TODO: do the same for params
name: string | void
+ matched: RouteRecord[] // non-enumerable
}
// interface PropsTransformer {
path: string // | RegExp
component: TODO
name?: string
+ beforeEnter?: NavigationGuard
// props: PT
}
query: {},
hash: '',
fullPath: '/',
+ matched: [],
}
+// make matched non enumerable for easy printing
+Object.defineProperty(START_LOCATION_NORMALIZED, 'matched', {
+ enumerable: false,
+})
+
// Matcher types
// the matcher doesn't care about query and hash
export type MatcherLocation =
path: string
// record?
params: RouteLocationNormalized['params']
+ matched: RouteRecord[]
}
export interface NavigationGuardCallback {