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

Add the ability to push, replace, and reject within prop callback via `CallbackContext #329

Merged
merged 12 commits into from
Nov 30, 2024
183 changes: 181 additions & 2 deletions src/components/routerView.browser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { mount, flushPromises } from '@vue/test-utils'
import { expect, test } from 'vitest'
import { defineAsyncComponent } from 'vue'
import { defineAsyncComponent, h } from 'vue'
import echo from '@/components/echo'
import helloWorld from '@/components/helloWorld'
import { createRoute } from '@/services/createRoute'
import { createRouter } from '@/services/createRouter'
import { isWithComponent } from '@/types/createRouteOptions'
import { routes } from '@/utilities/testHelpers'
import { component, routes } from '@/utilities/testHelpers'
import routerLink from '@/components/routerLink.vue'

test('renders component for initial route', async () => {
const route = createRoute({
Expand Down Expand Up @@ -381,3 +382,181 @@ test('Updates props and attrs when route params change', async () => {

expect(app.html()).toBe('async-bar')
})

test('Props from route can trigger push', async () => {

const routeA = createRoute({
name: 'routeA',
path: '/routeA',
component: echo,
props: (__, context) => {
throw context.push('routeB')
},
})

const routeB = createRoute({
name: 'routeB',
path: '/routeB',
component: echo,
props: () => ({
value: 'routeB',
}),
})

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

await router.start()

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

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

await router.push('/routeA')

await flushPromises()

expect(app.html()).toBe('routeB')
})

test('Props from route can trigger reject', async () => {
const routeA = createRoute({
name: 'routeA',
path: '/routeA',
component: echo,
props: (__, context) => {
throw context.reject('NotFound')
},
})

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

await router.start()

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

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

await router.push('/routeA')

await flushPromises()

expect(app.html()).toBe('<h1>NotFound</h1>')
})

test('prefetched props trigger push when navigation is initiated', async () => {
const routeA = createRoute({
name: 'routeA',
path: '/routeA',
component: { render: () => h(routerLink, { to: (resolve) => resolve('routeB') }, () => 'routeB') },
})

const routeB = createRoute({
name: 'routeB',
path: '/routeB',
component: echo,
prefetch: { props: true },
props: (__, { push }) => {
throw push('routeC')
},
})

const routeC = createRoute({
name: 'routeC',
path: '/routeC',
component: echo,
props: () => ({
value: 'routeC',
}),
})

const router = createRouter([routeA, routeB, routeC], {
initialUrl: '/routeA',
})

await router.start()

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

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

expect(app.text()).toBe('routeB')

app.find('a').trigger('click')

await flushPromises()

expect(app.text()).toBe('routeC')
})

test('prefetched async props trigger push when navigation is initiated', async () => {
const routeA = createRoute({
name: 'routeA',
path: '/routeA',
component: { render: () => h(routerLink, { to: (resolve) => resolve('routeB') }, () => 'routeB') },
})

const routeB = createRoute({
name: 'routeB',
path: '/routeB',
component,
prefetch: { props: true },
props: async (__, { push }) => {
throw push('routeC')
},
})

const routeC = createRoute({
name: 'routeC',
path: '/routeC',
component: echo,
props: () => ({
value: 'routeC',
}),
})

const router = createRouter([routeA, routeB, routeC], {
initialUrl: '/routeA',
})

await router.start()

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

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

expect(app.text()).toBe('routeB')

app.find('a').trigger('click')

await flushPromises()

expect(app.text()).toBe('routeC')
})
19 changes: 15 additions & 4 deletions src/services/component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable vue/one-component-per-file */
import { AsyncComponentLoader, Component, FunctionalComponent, defineComponent, h, ref } from 'vue'
import { MaybePromise } from '@/types/utilities'
import { isPromise } from '@/utilities/promises'

type Constructor = new (...args: any) => any

Expand Down Expand Up @@ -39,11 +40,17 @@ export function component<TComponent extends Component>(component: TComponent, p
setup() {
const values = props()

if ('then' in values) {
return () => h(asyncPropsWrapper(component, values))
}
return () => {
if (values instanceof Error) {
return ''
}

if (isPromise(values)) {
return h(asyncPropsWrapper(component, values))
}

return () => h(component, values)
return h(component, values)
}
},
})
}
Expand All @@ -60,6 +67,10 @@ function asyncPropsWrapper<TComponent extends Component>(component: TComponent,
})()

return () => {
if (values.value instanceof Error) {
return ''
}

if (values.value) {
return h(component, values.value)
}
Expand Down
68 changes: 54 additions & 14 deletions src/services/createPropStore.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
import { InjectionKey, reactive } from 'vue'
import { isWithComponent, isWithComponents } from '@/types/createRouteOptions'
import { getPrefetchOption, PrefetchConfigs } from '@/types/prefetch'
import { ResolvedRoute } from '@/types/resolved'
import { ResolvedRoute } from '@/types/resolved'
import { Route } from '@/types/route'
import { CallbackContext, CallbackPushResponse, CallbackRejectResponse, CallbackSuccessResponse, createCallbackContext } from './createCallbackContext'
import { CallbackContextPushError } from '@/errors/callbackContextPushError'
import { CallbackContextRejectionError } from '@/errors/callbackContextRejectionError'
import { getPropsValue } from '@/utilities/props'

export const propStoreKey: InjectionKey<PropStore> = Symbol()

type ComponentProps = { id: string, name: string, props?: (params: Record<string, unknown>) => unknown }
type ComponentProps = { id: string, name: string, props?: (params: Record<string, unknown>, context: CallbackContext) => unknown }

type SetPropsResponse = CallbackSuccessResponse | CallbackPushResponse | CallbackRejectResponse

export type PropStore = {
getPrefetchProps: (route: ResolvedRoute, prefetch: PrefetchConfigs) => Record<string, unknown>,
setPrefetchProps: (props: Record<string, unknown>) => void,
setProps: (route: ResolvedRoute) => void,
setProps: (route: ResolvedRoute) => Promise<SetPropsResponse>,
getProps: (id: string, name: string, route: ResolvedRoute) => unknown,
}

export function createPropStore(): PropStore {
const store: Map<string, unknown> = reactive(new Map())
const context = createCallbackContext()

const getPrefetchProps: PropStore['getPrefetchProps'] = (route, prefetch) => {
return route.matches
.filter((match) => getPrefetchOption({ ...prefetch, routePrefetch: match.prefetch }, 'props'))
.flatMap((match) => getComponentProps(match))
.reduce<Record<string, unknown>>((response, { id, name, props }) => {
if (!props) {
return response
}

const key = getPropKey(id, name, route)
const value = getPropsValue(() => props(route.params, context))

response[key] = props?.(route.params)
response[key] = value

return response
}, {})
Expand All @@ -37,24 +49,52 @@ export function createPropStore(): PropStore {
})
}

const setProps: PropStore['setProps'] = (route) => {
const setProps: PropStore['setProps'] = async (route) => {
const componentProps = route.matches.flatMap(getComponentProps)
const routeKeys = componentProps.reduce<string[]>((routeKeys, { id, name, props }) => {
const keys: string[] = []
const promises: Promise<unknown>[] = []

for (const { id, name, props } of componentProps) {
if (!props) {
continue
}

const key = getPropKey(id, name, route)

if (!props || store.has(key)) {
return routeKeys
keys.push(key)

if (!store.has(key)) {
const value = getPropsValue(() => props(route.params, context))

store.set(key, value)
}

const value = props(route.params)
promises.push((async () => {
const value = await store.get(key)

store.set(key, value)
routeKeys.push(key)
if (value instanceof Error) {
throw value
}
})())
}

clearUnusedStoreEntries(keys)

try {
await Promise.all(promises)

return routeKeys
}, [])
return { status: 'SUCCESS' }
} catch (error) {
if (error instanceof CallbackContextPushError) {
return error.response
}

if (error instanceof CallbackContextRejectionError) {
return error.response
}

clearUnusedStoreEntries(routeKeys)
throw error
}
}

const getProps: PropStore['getProps'] = (id, name, route) => {
Expand Down
8 changes: 2 additions & 6 deletions src/services/createRouteId.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
function uniqueIdSequence(): () => string {
let currentId = 0
import { createUniqueIdSequence } from './createUniqueIdSequence'

return () => (++currentId).toString()
}

export const createRouteId = uniqueIdSequence()
export const createRouteId = createUniqueIdSequence()
Loading