Skip to content

Commit

Permalink
Merge pull request #397 from kitbagjs/plugins
Browse files Browse the repository at this point in the history
New Feature: Router Plugins
  • Loading branch information
pleek91 authored Dec 31, 2024
2 parents 722b22c + 2db9a91 commit 0cf3f80
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 41 deletions.
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']> = (
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

0 comments on commit 0cf3f80

Please sign in to comment.