diff --git a/docs/advanced-concepts/hooks.md b/docs/advanced-concepts/hooks.md index adbc34be..816f8082 100644 --- a/docs/advanced-concepts/hooks.md +++ b/docs/advanced-concepts/hooks.md @@ -87,3 +87,17 @@ onAfterRouteEnter((to, context) => { :::warning You cannot register `onBeforeRouteEnter` or `onAfterRouteEnter` hooks from within a component, since the component must have been mounted to discover the hook. ::: + +## Global Injection + +Hooks are run within the context of the Vue app the router is installed. This means you can use vue's `inject` function to access global values. + +```ts +import { inject } from 'vue' + +router.onAfterRouteEnter(() => { + const value = inject('global') + + ... +}) +``` \ No newline at end of file diff --git a/docs/core-concepts/component-props.md b/docs/core-concepts/component-props.md index 4fae03eb..4762972e 100644 --- a/docs/core-concepts/component-props.md +++ b/docs/core-concepts/component-props.md @@ -106,3 +106,19 @@ const user = createRoute({ } })) ``` + +## Global Injection + +Props are run within the context of the Vue app the router is installed. This means you can use vue's `inject` function to access global values. + +```ts +import { inject } from 'vue' + +const route = createRoute({ + ... +}, async () => { + const value = inject('global') + + return { value } +}) +``` \ No newline at end of file diff --git a/src/services/createPropStore.ts b/src/services/createPropStore.ts index 15bc9020..f608e3f0 100644 --- a/src/services/createPropStore.ts +++ b/src/services/createPropStore.ts @@ -1,4 +1,4 @@ -import { InjectionKey, reactive } from 'vue' +import { App, InjectionKey, reactive } from 'vue' import { isWithComponentProps, isWithComponentPropsRecord, PropsGetter } from '@/types/createRouteOptions' import { getPrefetchOption, PrefetchConfigs, PrefetchStrategy } from '@/types/prefetch' import { ResolvedRoute } from '@/types/resolved' @@ -20,9 +20,12 @@ export type PropStore = { setPrefetchProps: (props: Record) => void, setProps: (route: ResolvedRoute) => Promise, getProps: (id: string, name: string, route: ResolvedRoute) => unknown, + setVueApp: (app: App) => void, } export function createPropStore(): PropStore { + let vueApp: App | null = null + const store: Map = reactive(new Map()) const { push, replace, reject } = createCallbackContext() @@ -41,7 +44,7 @@ export function createPropStore(): PropStore { replace, reject, parent: getParentContext(route, true), - })) + }), vueApp) response[key] = value @@ -75,7 +78,7 @@ export function createPropStore(): PropStore { replace, reject, parent: getParentContext(route), - })) + }), vueApp) store.set(key, value) } @@ -114,6 +117,10 @@ export function createPropStore(): PropStore { return store.get(key) } + const setVueApp: PropStore['setVueApp'] = (app) => { + vueApp = app + } + function getParentContext(route: ResolvedRoute, prefetch: boolean = false): PropsCallbackParent { const parent = route.matches.at(-2) @@ -204,5 +211,6 @@ export function createPropStore(): PropStore { setPrefetchProps, getProps, setProps, + setVueApp, } } diff --git a/src/services/createRouter.ts b/src/services/createRouter.ts index efccf3b7..e2058658 100644 --- a/src/services/createRouter.ts +++ b/src/services/createRouter.ts @@ -78,6 +78,8 @@ export function createRouter< const TOptions extends RouterOptions, const TPlugin extends RouterPlugin = EmptyRouterPlugin >(routesOrArrayOfRoutes: TRoutes | TRoutes[], options?: TOptions, plugins: TPlugin[] = []): Router { + let vueApp: App | null = null + const routes = getRoutesForRouter(routesOrArrayOfRoutes, plugins, options?.base) const hooks = createRouterHooks() @@ -113,7 +115,7 @@ export function createRouter< const to = find(url, options) ?? getRejectionRoute('NotFound') const from = getFromRouteForHooks(navigationId) - const beforeResponse = await hooks.runBeforeRouteHooks({ to, from }) + const beforeResponse = await hooks.runBeforeRouteHooks({ to, from, app: vueApp }) switch (beforeResponse.status) { // On abort do nothing @@ -169,7 +171,7 @@ export function createRouter< updateRoute(to) - const afterResponse = await hooks.runAfterRouteHooks({ to, from }) + const afterResponse = await hooks.runAfterRouteHooks({ to, from, app: vueApp }) switch (afterResponse.status) { case 'PUSH': @@ -309,6 +311,10 @@ export function createRouter< } function install(app: App): void { + vueApp = app + + propStore.setVueApp(app) + app.component('RouterView', RouterView) app.component('RouterLink', RouterLink) app.provide(routerRejectionKey, rejection) diff --git a/src/services/createRouterHooks.ts b/src/services/createRouterHooks.ts index 3aaf2762..df1a91d8 100644 --- a/src/services/createRouterHooks.ts +++ b/src/services/createRouterHooks.ts @@ -8,6 +8,7 @@ import { CallbackContextPushError } from '@/errors/callbackContextPushError' import { CallbackContextRejectionError } from '@/errors/callbackContextRejectionError' import { CallbackContextAbortError } from '@/errors/callbackContextAbortError' import { getGlobalAfterRouteHooks, getGlobalBeforeRouteHooks } from './getGlobalRouteHooks' +import { runWithContext } from '@/utilities/runWithContext' export const routerHooksKey: InjectionKey = Symbol() @@ -69,7 +70,7 @@ export function createRouterHooks(): RouterHooks { return () => store.global.onAfterRouteLeave.delete(hook) } - async function runBeforeRouteHooks({ to, from }: BeforeHookContext): Promise { + async function runBeforeRouteHooks({ to, from, app }: BeforeHookContext): Promise { const { global, component } = store const route = getBeforeRouteHooksFromRoutes(to, from) const globalHooks = getGlobalBeforeRouteHooks(to, from, global) @@ -86,13 +87,17 @@ export function createRouterHooks(): RouterHooks { ] try { - const results = allHooks.map((callback) => callback(to, { - from, - reject, - push, - replace, - abort, - })) + const results = allHooks.map((callback) => { + const fn = () => callback(to, { + from, + reject, + push, + replace, + abort, + }) + + return runWithContext(fn, app) + }) await Promise.all(results) } catch (error) { @@ -116,7 +121,7 @@ export function createRouterHooks(): RouterHooks { } } - async function runAfterRouteHooks({ to, from }: AfterHookContext): Promise { + async function runAfterRouteHooks({ to, from, app }: AfterHookContext): Promise { const { global, component } = store const route = getAfterRouteHooksFromRoutes(to, from) const globalHooks = getGlobalAfterRouteHooks(to, from, global) @@ -134,12 +139,16 @@ export function createRouterHooks(): RouterHooks { ] try { - const results = allHooks.map((callback) => callback(to, { - from, - reject, - push, - replace, - })) + const results = allHooks.map((callback) => { + const fn = () => callback(to, { + from, + reject, + push, + replace, + }) + + return runWithContext(fn, app) + }) await Promise.all(results) } catch (error) { diff --git a/src/services/hooks.spec.ts b/src/services/hooks.spec.ts index dd186713..6f118673 100644 --- a/src/services/hooks.spec.ts +++ b/src/services/hooks.spec.ts @@ -53,6 +53,7 @@ test('calls hook with correct routes', () => { runBeforeRouteHooks({ to: toRoute, from: fromRoute, + app: null, }) expect(hook).toHaveBeenCalledOnce() @@ -125,6 +126,7 @@ test.each<{ type: string, status: string, hook: BeforeRouteHook }>([ const response = await runBeforeRouteHooks({ to, from, + app: null, }) expect(response.status).toBe(status) @@ -180,6 +182,7 @@ test('hook is called in order', async () => { await runBeforeRouteHooks({ to, from, + app: null, }) const [orderA] = hookA.mock.invocationCallOrder diff --git a/src/tests/hooks.spec.ts b/src/tests/hooks.spec.ts new file mode 100644 index 00000000..cfe436fe --- /dev/null +++ b/src/tests/hooks.spec.ts @@ -0,0 +1,55 @@ +import echo from "@/components/echo"; +import { createRoute } from "@/services/createRoute"; +import { createRouter } from "@/services/createRouter"; +import { component } from "@/utilities"; +import { expect, test, vi } from "vitest"; +import { createApp, inject } from "vue"; + +test('hooks are run with the correct context', async () => { + const beforeRouteHook = vi.fn() + const afterRouteHook = vi.fn() + const beforeGlobalHook = vi.fn() + const afterGlobalHook = vi.fn() + + const route = createRoute({ + path: '/', + name: 'route', + component: echo, + onBeforeRouteEnter: () => { + const value = inject('global') + + beforeRouteHook(value) + }, + onAfterRouteEnter: () => { + const value = inject('global') + + afterRouteHook(value) + } + }, () => ({ value: 'hello' })) + + const router = createRouter([route], { + initialUrl: '/', + onBeforeRouteEnter: () => { + const value = inject('global') + + beforeGlobalHook(value) + }, + onAfterRouteEnter: () => { + const value = inject('global') + + afterGlobalHook(value) + } + }) + + const app = createApp(component) + + app.provide('global', 'hello world') + app.use(router) + + await router.start() + + expect(beforeRouteHook).toHaveBeenCalledWith('hello world') + expect(afterRouteHook).toHaveBeenCalledWith('hello world') + expect(beforeGlobalHook).toHaveBeenCalledWith('hello world') + expect(afterGlobalHook).toHaveBeenCalledWith('hello world') +}) diff --git a/src/tests/routeProps.spec.ts b/src/tests/routeProps.spec.ts index de454e99..d19c91b9 100644 --- a/src/tests/routeProps.spec.ts +++ b/src/tests/routeProps.spec.ts @@ -1,6 +1,8 @@ import { expect, test, vi } from 'vitest' import { createRoute } from '@/services/createRoute' import { createRouter } from '@/services/createRouter' +import { createApp, inject } from 'vue' +import { component } from '@/utilities/testHelpers' test('props are called each time the route is matched', async () => { const props = vi.fn() @@ -28,3 +30,31 @@ test('props are called each time the route is matched', async () => { expect(props).toHaveBeenCalledTimes(3) }) + +test('props are called with the correct context', async () => { + const props = vi.fn() + + const route = createRoute({ + name: 'route', + path: '/', + }, () => { + const value = inject('global') + + props(value) + + return {} + }) + + const router = createRouter([route], { + initialUrl: '/', + }) + + const app = createApp(component) + + app.provide('global', 'hello world') + app.use(router) + + await router.start() + + expect(props).toHaveBeenCalledWith('hello world') +}) diff --git a/src/types/hooks.ts b/src/types/hooks.ts index d0b23b1c..a0405450 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -2,6 +2,7 @@ import { RouteHooks } from '@/models/RouteHooks' import { CallbackAbortResponse, CallbackContext, CallbackContextAbort, CallbackPushResponse, CallbackRejectResponse, CallbackSuccessResponse } from '@/services/createCallbackContext' import { ResolvedRoute } from '@/types/resolved' import { MaybeArray, MaybePromise } from '@/types/utilities' +import { App } from 'vue' /** * Defines route hooks that can be applied before entering, updating, or leaving a route, as well as after these events. @@ -18,6 +19,7 @@ export type WithHooks = { export type BeforeHookContext = { to: ResolvedRoute, from: ResolvedRoute | null, + app: App | null, } export type RouteHookBeforeRunner = (context: BeforeHookContext) => Promise @@ -25,6 +27,7 @@ export type RouteHookBeforeRunner = (context: BeforeHookContext) => Promise Promise diff --git a/src/utilities/props.ts b/src/utilities/props.ts index c066c1c7..e391e10d 100644 --- a/src/utilities/props.ts +++ b/src/utilities/props.ts @@ -1,13 +1,15 @@ +import { App } from 'vue' import { isPromise } from './promises' +import { runWithContext } from './runWithContext' /** * Takes a props callback and returns a value * For sync props, this will return the props value or an error. * For async props, this will return a promise that resolves to the props value or an error. */ -export function getPropsValue(callback: () => unknown): unknown { +export function getPropsValue(callback: () => unknown, app: App | null): unknown { try { - const value = callback() + const value = runWithContext(callback, app) if (isPromise(value)) { return value.catch((error: unknown) => error) diff --git a/src/utilities/runWithContext.ts b/src/utilities/runWithContext.ts new file mode 100644 index 00000000..d432b37e --- /dev/null +++ b/src/utilities/runWithContext.ts @@ -0,0 +1,9 @@ +import { App } from "vue" + +export function runWithContext(callback: () => T, app: App | null): T { + if (!app) { + return callback() + } + + return app.runWithContext(callback) +} \ No newline at end of file