Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Feature: Router Plugins #397

Merged
merged 9 commits into from
Dec 31, 2024
54 changes: 54 additions & 0 deletions src/services/createRouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,3 +671,57 @@ describe('router.push', () => {
expect(router.route.state).toMatchObject({ zoo: 123 })
})
})

test('global hooks are called correctly', async () => {
const router = createRouter(routes, { initialUrl: '/parentA/valueA' })

const onBeforeRouteEnter = vi.fn()
const onBeforeRouteUpdate = vi.fn()
const onBeforeRouteLeave = vi.fn()
const onAfterRouteEnter = vi.fn()
const onAfterRouteUpdate = vi.fn()
const onAfterRouteLeave = vi.fn()

router.onBeforeRouteEnter(onBeforeRouteEnter)
router.onAfterRouteEnter(onAfterRouteEnter)
router.onBeforeRouteUpdate(onBeforeRouteUpdate)
router.onAfterRouteUpdate(onAfterRouteUpdate)
router.onBeforeRouteLeave(onBeforeRouteLeave)
router.onAfterRouteLeave(onAfterRouteLeave)

await router.start()

expect(onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(0)
expect(onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteUpdate).toHaveBeenCalledTimes(0)
expect(onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentA.childA', { paramA: 'valueA', paramB: 'valueB' })

expect(onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(1)
expect(onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteUpdate).toHaveBeenCalledTimes(1)
expect(onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentA.childB', { paramA: 'valueB', paramD: 'valueD' })

expect(onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(2)
expect(onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(onAfterRouteUpdate).toHaveBeenCalledTimes(2)
expect(onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentB')

expect(onBeforeRouteEnter).toHaveBeenCalledTimes(2)
expect(onBeforeRouteUpdate).toHaveBeenCalledTimes(2)
expect(onBeforeRouteLeave).toHaveBeenCalledTimes(2)
expect(onAfterRouteLeave).toHaveBeenCalledTimes(2)
expect(onAfterRouteUpdate).toHaveBeenCalledTimes(2)
expect(onAfterRouteEnter).toHaveBeenCalledTimes(2)
})
66 changes: 49 additions & 17 deletions src/services/createRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { RouterReplace, RouterReplaceOptions } from '@/types/routerReplace'
import { RoutesName } from '@/types/routesMap'
import { Url, isUrl } from '@/types/url'
import { checkDuplicateNames } from '@/utilities/checkDuplicateNames'
import { isNestedArray } from '@/utilities/guards'
import { createUniqueIdSequence } from '@/services/createUniqueIdSequence'
import { createVisibilityObserver } from './createVisibilityObserver'
import { visibilityObserverKey } from '@/compositions/useVisibilityObserver'
Expand All @@ -32,6 +31,8 @@ import { ResolvedRoute } from '@/types/resolved'
import { createResolvedRouteForUrl } from '@/services/createResolvedRouteForUrl'
import { combineUrl } from '@/services/urlCombine'
import { RouterReject } from '@/types/routerReject'
import { EmptyRouterPlugin, RouterPlugin } from '@/types/routerPlugin'
import { addRouterPluginHooks } from './createRouterPlugin'

type RouterUpdateOptions = {
replace?: boolean,
Expand Down Expand Up @@ -61,11 +62,25 @@ type RouterUpdateOptions = {
* const router = createRouter(routes)
* ```
*/
export function createRouter<const TRoutes extends Routes, const TOptions extends RouterOptions>(routes: TRoutes, options?: TOptions): Router<TRoutes, TOptions>
export function createRouter<const TRoutes extends Routes, const TOptions extends RouterOptions>(arrayOfRoutes: TRoutes[], options?: TOptions): Router<TRoutes, TOptions>
export function createRouter<const TRoutes extends Routes, const TOptions extends RouterOptions>(routesOrArrayOfRoutes: TRoutes | TRoutes[], options?: TOptions): Router<TRoutes, TOptions> {
const flattenedRoutes = isNestedArray(routesOrArrayOfRoutes) ? routesOrArrayOfRoutes.flat() : routesOrArrayOfRoutes
const routes = insertBaseRoute(flattenedRoutes, options?.base)
export function createRouter<
const TRoutes extends Routes,
const TOptions extends RouterOptions,
const TPlugin extends RouterPlugin = EmptyRouterPlugin
>(routes: TRoutes, options?: TOptions, plugins?: TPlugin[]): Router<TRoutes, TOptions, TPlugin>

export function createRouter<
const TRoutes extends Routes,
const TOptions extends RouterOptions,
const TPlugin extends RouterPlugin = EmptyRouterPlugin
>(arrayOfRoutes: TRoutes[], options?: TOptions, plugins?: TPlugin[]): Router<TRoutes, TOptions, TPlugin>

export function createRouter<
const TRoutes extends Routes,
const TOptions extends RouterOptions,
const TPlugin extends RouterPlugin = EmptyRouterPlugin
>(routesOrArrayOfRoutes: TRoutes | TRoutes[], options?: TOptions, plugins: TPlugin[] = []): Router<TRoutes, TOptions, TPlugin> {
const pluginRoutes = plugins.map((plugin) => plugin.routes)
const routes = insertBaseRoute([...routesOrArrayOfRoutes, ...pluginRoutes].flat(), options?.base)

checkDuplicateNames(routes)

Expand Down Expand Up @@ -187,8 +202,8 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
history.startListening()
}

const resolve: RouterResolve<TRoutes> = (
source: RoutesName<TRoutes>,
const resolve: RouterResolve<TRoutes | TPlugin['routes']> = (
pleek91 marked this conversation as resolved.
Show resolved Hide resolved
source: RoutesName<TRoutes | TPlugin['routes']>,
params: Record<string, unknown> = {},
options: RouterResolveOptions = {},
) => {
Expand All @@ -201,8 +216,8 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
return createResolvedRoute(match, params, options)
}

const push: RouterPush<TRoutes> = (
source: Url | RoutesName<TRoutes> | ResolvedRoute,
const push: RouterPush<TRoutes | TPlugin['routes']> = (
source: Url | RoutesName<TRoutes | TPlugin['routes']> | ResolvedRoute,
paramsOrOptions?: Record<string, unknown> | RouterPushOptions,
maybeOptions?: RouterPushOptions,
) => {
Expand Down Expand Up @@ -236,7 +251,11 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
return set(url, { replace, state })
}

const replace: RouterReplace<TRoutes> = (source: Url | RoutesName<TRoutes> | ResolvedRoute, paramsOrOptions?: Record<string, unknown> | RouterReplaceOptions, maybeOptions?: RouterReplaceOptions) => {
const replace: RouterReplace<TRoutes | TPlugin['routes']> = (
source: Url | RoutesName<TRoutes | TPlugin['routes']> | ResolvedRoute,
paramsOrOptions?: Record<string, unknown> | RouterReplaceOptions,
maybeOptions?: RouterReplaceOptions,
) => {
if (isUrl(source)) {
const options: RouterPushOptions = { ...paramsOrOptions, replace: true }

Expand All @@ -255,15 +274,17 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
return push(source, options)
}

const reject: RouterReject<keyof TOptions['rejections']> = (type) => {
const reject: RouterReject<keyof TOptions['rejections'] | keyof TPlugin['rejections']> = (type) => {
setRejection(type)
}

const { setRejection, rejection, getRejectionRoute } = createRouterReject(options ?? {})
const notFoundRoute = getRejectionRoute('NotFound')
const { currentRoute, routerRoute, updateRoute } = createCurrentRoute<TRoutes>(notFoundRoute, push)
const { setRejection, rejection, getRejectionRoute } = createRouterReject({
...plugins.reduce((rejections, plugin) => ({ ...rejections, ...plugin.rejections }), {}),
...options?.rejections,
})

history.startListening()
const notFoundRoute = getRejectionRoute('NotFound')
const { currentRoute, routerRoute, updateRoute } = createCurrentRoute<TRoutes | TPlugin['routes']>(notFoundRoute, push)

const initialUrl = getInitialUrl(options?.initialUrl)
const initialState = history.location.state
Expand All @@ -285,9 +306,15 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend

await set(initialUrl, { replace: true, state: initialState })

history.startListening()

initialized()
}

function stop(): void {
history.stopListening()
}

function install(app: App): void {
app.component('RouterView', RouterView)
app.component('RouterLink', RouterLink)
Expand All @@ -303,7 +330,7 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
start()
}

const router: Router<TRoutes, TOptions> = {
const router: Router<TRoutes, TOptions, TPlugin> = {
route: routerRoute,
resolve,
find,
Expand All @@ -324,7 +351,12 @@ export function createRouter<const TRoutes extends Routes, const TOptions extend
onAfterRouteLeave,
prefetch: options?.prefetch,
start,
stop,
}

plugins.forEach((plugin) => {
addRouterPluginHooks(router, plugin)
})

return router
}
106 changes: 106 additions & 0 deletions src/services/createRouterPlugin.browser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { test, expect, vi } from 'vitest'
import { createRoute } from './createRoute'
import { createRouter } from './createRouter'
import { createRouterPlugin } from './createRouterPlugin'
import { component, routes } from '@/utilities/testHelpers'
import { flushPromises } from '@vue/test-utils'
import { mount } from '@vue/test-utils'

test('given a plugin, adds the routes to the router', async () => {
const plugin = createRouterPlugin({
routes: [createRoute({ name: 'plugin', path: '/plugin', component })],
rejections: {
plugin: component,
},
})

const router = createRouter([], { initialUrl: '/plugin' }, [plugin])

await router.start()

expect(router.route.name).toBe('plugin')
})

test('given a plugin, adds the rejections to the router', async () => {
const plugin = createRouterPlugin({
rejections: {
plugin: { template: '<div>This is a plugin rejection</div>' },
},
})

const router = createRouter([], { initialUrl: '/' }, [plugin])

await router.start()

const root = {
template: '<RouterView/>',
}

const wrapper = mount(root, {
global: {
plugins: [router],
},
})

await router.reject('plugin')

await flushPromises()

expect(wrapper.html()).toBe('<div>This is a plugin rejection</div>')
})

test('given a plugin, adds the hooks to the router', async () => {
const plugin = createRouterPlugin({
onBeforeRouteEnter: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
onBeforeRouteLeave: vi.fn(),
onAfterRouteEnter: vi.fn(),
onAfterRouteUpdate: vi.fn(),
onAfterRouteLeave: vi.fn(),
})

const router = createRouter(routes, { initialUrl: '/parentA/valueA' }, [plugin])

expect(plugin.onBeforeRouteEnter).toHaveBeenCalledTimes(0)
expect(plugin.onBeforeRouteUpdate).toHaveBeenCalledTimes(0)
expect(plugin.onBeforeRouteLeave).toHaveBeenCalledTimes(0)
expect(plugin.onAfterRouteLeave).toHaveBeenCalledTimes(0)
expect(plugin.onAfterRouteUpdate).toHaveBeenCalledTimes(0)
expect(plugin.onAfterRouteEnter).toHaveBeenCalledTimes(0)

await router.start()

expect(plugin.onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(plugin.onBeforeRouteUpdate).toHaveBeenCalledTimes(0)
expect(plugin.onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteUpdate).toHaveBeenCalledTimes(0)
expect(plugin.onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentA.childA', { paramA: 'valueA', paramB: 'valueB' })

expect(plugin.onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(plugin.onBeforeRouteUpdate).toHaveBeenCalledTimes(1)
expect(plugin.onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteUpdate).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentA.childB', { paramA: 'valueB', paramD: 'valueD' })

expect(plugin.onBeforeRouteEnter).toHaveBeenCalledTimes(1)
expect(plugin.onBeforeRouteUpdate).toHaveBeenCalledTimes(2)
expect(plugin.onBeforeRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteLeave).toHaveBeenCalledTimes(1)
expect(plugin.onAfterRouteUpdate).toHaveBeenCalledTimes(2)
expect(plugin.onAfterRouteEnter).toHaveBeenCalledTimes(1)

await router.push('parentB')

expect(plugin.onBeforeRouteEnter).toHaveBeenCalledTimes(2)
expect(plugin.onBeforeRouteUpdate).toHaveBeenCalledTimes(2)
expect(plugin.onBeforeRouteLeave).toHaveBeenCalledTimes(2)
expect(plugin.onAfterRouteLeave).toHaveBeenCalledTimes(2)
expect(plugin.onAfterRouteUpdate).toHaveBeenCalledTimes(2)
expect(plugin.onAfterRouteEnter).toHaveBeenCalledTimes(2)
})
42 changes: 42 additions & 0 deletions src/services/createRouterPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Routes } from '@/types/route'
import { Router } from '@/types/router'
import { RouterPlugin } from '@/types/routerPlugin'
import { asArray } from '@/utilities/array'
import { Component } from 'vue'

export function createRouterPlugin<
TRoutes extends Routes = [],
TRejections extends Record<string, Component> = {}
>(plugin: Partial<RouterPlugin<TRoutes, TRejections>>): RouterPlugin<TRoutes, TRejections> {
return {
routes: plugin.routes ?? [] as unknown as TRoutes,
rejections: plugin.rejections ?? {} as TRejections,
...plugin,
}
}

export function addRouterPluginHooks(router: Router, plugin: RouterPlugin): void {
if (plugin.onBeforeRouteEnter) {
asArray(plugin.onBeforeRouteEnter).forEach((hook) => router.onBeforeRouteEnter(hook))
}

if (plugin.onAfterRouteEnter) {
asArray(plugin.onAfterRouteEnter).forEach((hook) => router.onAfterRouteEnter(hook))
}

if (plugin.onBeforeRouteUpdate) {
asArray(plugin.onBeforeRouteUpdate).forEach((hook) => router.onBeforeRouteUpdate(hook))
}

if (plugin.onAfterRouteUpdate) {
asArray(plugin.onAfterRouteUpdate).forEach((hook) => router.onAfterRouteUpdate(hook))
}

if (plugin.onBeforeRouteLeave) {
asArray(plugin.onBeforeRouteLeave).forEach((hook) => router.onBeforeRouteLeave(hook))
}

if (plugin.onAfterRouteLeave) {
asArray(plugin.onAfterRouteLeave).forEach((hook) => router.onAfterRouteLeave(hook))
}
}
17 changes: 3 additions & 14 deletions src/services/createRouterReject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,23 @@ import { createRouteId } from '@/services/createRouteId'
import { isRejectionRouteSymbol } from '@/services/isRejectionRoute'
import { ResolvedRoute } from '@/types/resolved'

export const builtInRejections: ['NotFound'] = ['NotFound']
export type BuiltInRejectionType = typeof builtInRejections[number]
export type BuiltInRejectionType = 'NotFound'

export type RouterSetReject = (type: string | null) => void

type GetRejectionRoute = (type: string) => ResolvedRoute

export type RouterRejection = Ref<null | { type: string, component: Component }>

type CreateRouterRejectContext = {
rejections?: Partial<Record<string, Component>>,
}

export type CreateRouterReject = {
setRejection: RouterSetReject,
rejection: RouterRejection,
getRejectionRoute: GetRejectionRoute,
}

export function createRouterReject({
rejections: customRejectionComponents,
}: CreateRouterRejectContext): CreateRouterReject {
export function createRouterReject(rejections: Partial<Record<string, Component>>): CreateRouterReject {
const getRejectionComponent = (type: string): Component => {
const components = {
...customRejectionComponents,
}

return markRaw(components[type] ?? genericRejection(type))
return markRaw(rejections[type] ?? genericRejection(type))
}

const getRejectionRoute: GetRejectionRoute = (type) => {
Expand Down
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './path'
export * from './query'
export type { RouterRoute } from './createRouterRoute'
export { withDefault } from './withDefault'
export { createRouterPlugin } from './createRouterPlugin'
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './url'
export type { ResolvedRoute } from './resolved'
export type { QuerySource } from './query'
export type { PrefetchConfig, PrefetchStrategy } from './prefetch'
export type { RouterPlugin } from './routerPlugin'
Loading
Loading