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

Run hooks and props within the context of the vue app #448

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/advanced-concepts/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')

...
})
```
16 changes: 16 additions & 0 deletions docs/core-concepts/component-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
})
```
14 changes: 11 additions & 3 deletions src/services/createPropStore.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,9 +20,12 @@ export type PropStore = {
setPrefetchProps: (props: Record<string, unknown>) => void,
setProps: (route: ResolvedRoute) => Promise<SetPropsResponse>,
getProps: (id: string, name: string, route: ResolvedRoute) => unknown,
setVueApp: (app: App) => void,
}

export function createPropStore(): PropStore {
let vueApp: App | null = null

const store: Map<string, unknown> = reactive(new Map())
const { push, replace, reject } = createCallbackContext()

Expand All @@ -41,7 +44,7 @@ export function createPropStore(): PropStore {
replace,
reject,
parent: getParentContext(route, true),
}))
}), vueApp)

response[key] = value

Expand Down Expand Up @@ -75,7 +78,7 @@ export function createPropStore(): PropStore {
replace,
reject,
parent: getParentContext(route),
}))
}), vueApp)

store.set(key, value)
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -204,5 +211,6 @@ export function createPropStore(): PropStore {
setPrefetchProps,
getProps,
setProps,
setVueApp,
}
}
10 changes: 8 additions & 2 deletions src/services/createRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export function createRouter<
const TOptions extends RouterOptions,
const TPlugin extends RouterPlugin = EmptyRouterPlugin
>(routesOrArrayOfRoutes: TRoutes | TRoutes[], options?: TOptions, plugins: TPlugin[] = []): Router<TRoutes, TOptions, TPlugin> {
let vueApp: App | null = null

const routes = getRoutesForRouter(routesOrArrayOfRoutes, plugins, options?.base)
const hooks = createRouterHooks()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -309,6 +311,10 @@ export function createRouter<
}

function install(app: App): void {
vueApp = app

propStore.setVueApp(app)
Comment on lines +314 to +316
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is overkill but thoughts on storing the vueApp in it's own "store"? That would solve 2 things I don't love about the current implementation.

  1. we use a let
  2. the hooks and props technically have independent references and ideally they'd look in the same place

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that new "store" could also be responsible for exporting the runWithContext piece

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't hate it, I'll look into that tomorrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stackoverfloweth looking at this again not sure how the new store would work. We don't really have a way for the propsStore to get something from a separate store. Maybe we can chat about this


app.component('RouterView', RouterView)
app.component('RouterLink', RouterLink)
app.provide(routerRejectionKey, rejection)
Expand Down
39 changes: 24 additions & 15 deletions src/services/createRouterHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterHooks> = Symbol()

Expand Down Expand Up @@ -69,7 +70,7 @@ export function createRouterHooks(): RouterHooks {
return () => store.global.onAfterRouteLeave.delete(hook)
}

async function runBeforeRouteHooks({ to, from }: BeforeHookContext): Promise<BeforeRouteHookResponse> {
async function runBeforeRouteHooks({ to, from, app }: BeforeHookContext): Promise<BeforeRouteHookResponse> {
const { global, component } = store
const route = getBeforeRouteHooksFromRoutes(to, from)
const globalHooks = getGlobalBeforeRouteHooks(to, from, global)
Expand All @@ -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) {
Expand All @@ -116,7 +121,7 @@ export function createRouterHooks(): RouterHooks {
}
}

async function runAfterRouteHooks({ to, from }: AfterHookContext): Promise<AfterRouteHookResponse> {
async function runAfterRouteHooks({ to, from, app }: AfterHookContext): Promise<AfterRouteHookResponse> {
const { global, component } = store
const route = getAfterRouteHooksFromRoutes(to, from)
const globalHooks = getGlobalAfterRouteHooks(to, from, global)
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/services/hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ test('calls hook with correct routes', () => {
runBeforeRouteHooks({
to: toRoute,
from: fromRoute,
app: null,
})

expect(hook).toHaveBeenCalledOnce()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -180,6 +182,7 @@ test('hook is called in order', async () => {
await runBeforeRouteHooks({
to,
from,
app: null,
})

const [orderA] = hookA.mock.invocationCallOrder
Expand Down
55 changes: 55 additions & 0 deletions src/tests/hooks.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
30 changes: 30 additions & 0 deletions src/tests/routeProps.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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')
})
3 changes: 3 additions & 0 deletions src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,13 +19,15 @@ export type WithHooks = {
export type BeforeHookContext = {
to: ResolvedRoute,
from: ResolvedRoute | null,
app: App | null,
}

export type RouteHookBeforeRunner = (context: BeforeHookContext) => Promise<BeforeRouteHookResponse>

export type AfterHookContext = {
to: ResolvedRoute,
from: ResolvedRoute | null,
app: App | null,
}

export type RouteHookAfterRunner = (context: AfterHookContext) => Promise<AfterRouteHookResponse>
Expand Down
6 changes: 4 additions & 2 deletions src/utilities/props.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/utilities/runWithContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { App } from "vue"

export function runWithContext<T>(callback: () => T, app: App | null): T {
if (!app) {
return callback()
}

return app.runWithContext(callback)
}