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

Prefetch async route components #255

Merged
merged 11 commits into from
Sep 3, 2024
140 changes: 138 additions & 2 deletions src/components/routerLink.browser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, test, vi } from 'vitest'
import { h } from 'vue'
import { defineAsyncComponent, h } from 'vue'
import routerLink from '@/components/routerLink.vue'
import { createRoute } from '@/services/createRoute'
import { createRouter } from '@/services/createRouter'
import { PrefetchConfig, getPrefetchConfigValue } from '@/types/prefetch'
import { component } from '@/utilities/testHelpers'

test('renders an anchor tag with the correct href and slot content', () => {
Expand Down Expand Up @@ -230,4 +231,139 @@ test.each([
const element = anchor.element as HTMLAnchorElement

expect(isExternal.toString()).toBe(element.innerHTML)
})

test.each<PrefetchConfig | undefined>([
undefined,
true,
false,
{ components: true },
{ components: false },
])('prefetch components respects router config when prefetch is %s', async (prefetch) => {
let loaded = undefined

const route = createRoute({
name: 'route',
path: '/route',
component: defineAsyncComponent(() => {
return new Promise(resolve => {
loaded = true
resolve({ default: { template: '' } })
})
}),
Comment on lines +249 to +252
Copy link
Contributor

Choose a reason for hiding this comment

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

clever!

})

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

mount(routerLink, {
props: {
to: '/route',
},
global: {
plugins: [router],
},
})

await flushPromises()

const value = getPrefetchConfigValue(prefetch, 'components')

if (value === true || value === undefined) {
expect(loaded).toBe(true)
} else {
expect(loaded).toBeUndefined()
}
})

test.each<PrefetchConfig | undefined>([
undefined,
true,
false,
{ components: true },
{ components: false },
])('prefetch components respects route config when prefetch is %s', async (prefetch) => {
let loaded = undefined

const route = createRoute({
name: 'route',
path: '/route',
prefetch,
component: defineAsyncComponent(() => {
return new Promise(resolve => {
loaded = true
resolve({ default: { template: '' } })
})
}),
})

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

mount(routerLink, {
props: {
to: '/route',
},
global: {
plugins: [router],
},
})

await flushPromises()

const value = getPrefetchConfigValue(prefetch, 'components')

if (value === true || value === undefined) {
expect(loaded).toBe(true)
} else {
expect(loaded).toBeUndefined()
}
})

test.each<PrefetchConfig | undefined>([
undefined,
true,
false,
{ components: true },
{ components: false },
])('prefetch components respects link config when prefetch is %s', async (prefetch) => {
let loaded = undefined

const route = createRoute({
name: 'route',
path: '/route',
component: defineAsyncComponent(() => {
return new Promise(resolve => {
loaded = true
resolve({ default: { template: '' } })
})
}),
})

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

mount(routerLink, {
props: {
to: '/route',
prefetch,
},
global: {
plugins: [router],
},
})

await flushPromises()

const value = getPrefetchConfigValue(prefetch, 'components')

if (value === true || value === undefined) {
expect(loaded).toBe(true)
} else {
expect(loaded).toBeUndefined()
}
})
20 changes: 18 additions & 2 deletions src/components/routerLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useLink } from '@/compositions'
import { PrefetchConfig } from '@/types/prefetch'
import { RegisteredRouter } from '@/types/register'
import { RouterPushOptions } from '@/types/routerPush'
import { Url, isUrl } from '@/types/url'

type ToCallback = (resolve: RegisteredRouter['resolve']) => string

const props = defineProps<{ to: Url | ToCallback } & RouterPushOptions>()
type RouterLinkProps = {
/**
* The url string to navigate to or a callback that returns a url string
*/
to: Url | ToCallback,
/**
* Determines what assets are prefetched when router-link is rendered for this route. Overrides route level prefetch.
*/
prefetch?: PrefetchConfig,
}

const props = withDefaults(defineProps<RouterLinkProps & RouterPushOptions>(), {
// because prefetch can be a boolean vue automatically sets the default to false.
// Specifically setting the default to undefined
prefetch: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

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

boo macros 👎

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In general I really like how vue defaults booleans to false. But it can cause some surprising errors when you're not really expecting it.

})

defineSlots<{
default?: (props: {
Expand All @@ -36,7 +52,7 @@
return options
})

const { href, isMatch, isExactMatch } = useLink(resolved)
const { href, isMatch, isExactMatch } = useLink(resolved, options)

const classes = computed(() => ({
'router-link--match': isMatch.value,
Expand Down
77 changes: 64 additions & 13 deletions src/compositions/useLink.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { MaybeRefOrGetter, Ref, computed, toValue } from 'vue'
import { MaybeRefOrGetter, Ref, computed, toRef, toValue, watch } from 'vue'
import { useRouter } from '@/compositions/useRouter'
import { InvalidRouteParamValueError } from '@/errors/invalidRouteParamValueError'
import { RouterResolveOptions } from '@/services/createRouterResolve'
import { isWithComponent, isWithComponents } from '@/types/createRouteOptions'
import { PrefetchConfig, getPrefetchOption } from '@/types/prefetch'
import { RegisteredRoutes, RegisteredRoutesName } from '@/types/register'
import { ResolvedRoute } from '@/types/resolved'
import { RouterPushOptions } from '@/types/routerPush'
import { RouterReplaceOptions } from '@/types/routerReplace'
import { RouteParamsByKey } from '@/types/routeWithParams'
import { Url, isUrl } from '@/types/url'
import { AllPropertiesAreOptional } from '@/types/utilities'
import { isAsyncComponent } from '@/utilities/components'

export type UseLink = {
/**
Expand Down Expand Up @@ -37,12 +40,16 @@ export type UseLink = {
replace: (options?: RouterReplaceOptions) => Promise<void>,
}

export type UseLinkOptions = RouterResolveOptions & {
prefetch?: PrefetchConfig,
}

type UseLinkArgs<
TSource extends RegisteredRoutesName,
TParams = RouteParamsByKey<RegisteredRoutes, TSource>
> = AllPropertiesAreOptional<TParams> extends true
? [params?: MaybeRefOrGetter<TParams>, options?: MaybeRefOrGetter<RouterResolveOptions>]
: [params: MaybeRefOrGetter<TParams>, options?: MaybeRefOrGetter<RouterResolveOptions>]
? [params?: MaybeRefOrGetter<TParams>, options?: MaybeRefOrGetter<UseLinkOptions>]
: [params: MaybeRefOrGetter<TParams>, options?: MaybeRefOrGetter<UseLinkOptions>]

/**
* A composition to export much of the functionality that drives RouterLink component. Can be given route details to discover resolved URL,
Expand All @@ -56,16 +63,16 @@ type UseLinkArgs<
*
*/
export function useLink<TRouteKey extends RegisteredRoutesName>(name: MaybeRefOrGetter<TRouteKey>, ...args: UseLinkArgs<TRouteKey>): UseLink
export function useLink(url: MaybeRefOrGetter<Url>): UseLink
export function useLink(url: MaybeRefOrGetter<Url>, options?: MaybeRefOrGetter<UseLinkOptions>): UseLink
export function useLink(
source: MaybeRefOrGetter<string>,
params: MaybeRefOrGetter<Record<PropertyKey, unknown>> = {},
options: MaybeRefOrGetter<RouterResolveOptions> = {},
paramsOrOptions: MaybeRefOrGetter<Record<PropertyKey, unknown> | UseLinkOptions> = {},
maybeOptions: MaybeRefOrGetter<UseLinkOptions> = {},
): UseLink {
const router = useRouter()
const sourceRef = computed(() => toValue(source))
const paramsRef = computed(() => toValue(params))
const optionsRef = computed(() => toValue(options))
const sourceRef = toRef(source)
const paramsRef = computed<Record<PropertyKey, unknown>>(() => isUrl(sourceRef.value) ? {} : toValue(paramsOrOptions))
const optionsRef = computed<UseLinkOptions>(() => isUrl(sourceRef.value) ? toValue(paramsOrOptions) : toValue(maybeOptions))

const href = computed(() => {
if (isUrl(sourceRef.value)) {
Expand All @@ -83,13 +90,24 @@ export function useLink(
}
})

const route = computed(() => {
return router.find(href.value, optionsRef.value)
})

const route = computed(() => router.find(href.value, optionsRef.value))
const isMatch = computed(() => !!route.value && router.route.matches.includes(route.value.matched))
const isExactMatch = computed(() => !!route.value && router.route.matched === route.value.matched)

watch(route, route => {
if (!route) {
return
}

const { prefetch: routerPrefetch } = router
const { prefetch: linkPrefetch } = optionsRef.value

prefetchComponentsForRoute(route, {
routerPrefetch,
linkPrefetch,
})
}, { immediate: true })

return {
route,
href,
Expand All @@ -98,4 +116,37 @@ export function useLink(
push: (options?: RouterPushOptions) => router.push(href.value, {}, { ...optionsRef.value, ...options }),
replace: (options?: RouterReplaceOptions) => router.replace(href.value, {}, { ...optionsRef.value, ...options }),
}
}

type ComponentPrefect = {
routerPrefetch: PrefetchConfig | undefined,
linkPrefetch: PrefetchConfig | undefined,
}

function prefetchComponentsForRoute(route: ResolvedRoute, { routerPrefetch, linkPrefetch }: ComponentPrefect): void {

route.matches.forEach(route => {
const shouldPrefetchComponents = getPrefetchOption({
routePrefetch: route.prefetch,
routerPrefetch,
linkPrefetch,
}, 'components')

if (!shouldPrefetchComponents) {
return
}

if (isWithComponent(route) && isAsyncComponent(route.component)) {
route.component.setup()
}

if (isWithComponents(route)) {
Object.values(route.components).forEach(component => {
if (isAsyncComponent(component)) {
component.setup()
}
})
}
})

}
1 change: 1 addition & 0 deletions src/services/createRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function createRoute(options: CreateRouteOptions): Route {
state,
depth: 1,
host: host('', {}),
prefetch: options.prefetch,
}

const merged = isWithParent(options) ? combineRoutes(options.parent, route) : route
Expand Down
1 change: 1 addition & 0 deletions src/services/createRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export function createRouter<const T extends Routes>(routesOrArrayOfRoutes: T |
onAfterRouteEnter,
onBeforeRouteUpdate,
onAfterRouteLeave,
prefetch: options.prefetch,
}

return router
Expand Down
5 changes: 5 additions & 0 deletions src/types/createRouteOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AfterRouteHook, BeforeRouteHook } from '@/types/hooks'
import { Host } from '@/types/host'
import { Param } from '@/types/paramTypes'
import { Path } from '@/types/path'
import { PrefetchConfig } from '@/types/prefetch'
import { Query } from '@/types/query'
import { RouteMeta } from '@/types/register'
import { Route } from '@/types/route'
Expand Down Expand Up @@ -127,6 +128,10 @@ export type CreateRouteOptions<
* Represents additional metadata associated with a route, customizable via declaration merging.
*/
meta?: TMeta,
/**
* Determines what assets are prefetched when router-link is rendered for this route. Overrides router level prefetch.
*/
prefetch?: PrefetchConfig,
}

export function combineRoutes(parent: Route, child: Route): Route {
Expand Down
36 changes: 36 additions & 0 deletions src/types/prefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isRecord } from '@/utilities'

export type PrefetchConfigOptions = {
/**
* When true any component that is wrapped in vue's defineAsyncComponent will be prefetched
* @default true
*/
components?: boolean,
}

/**
* Determines what assets are prefetched. A boolean enables or disables all prefetching.
*/
export type PrefetchConfig = boolean | PrefetchConfigOptions

export const DEFAULT_PREFETCH_CONFIG: Required<PrefetchConfigOptions> = {
components: true,
}

type PrefetchConfigs = {
routerPrefetch: PrefetchConfig | undefined,
routePrefetch: PrefetchConfig | undefined,
linkPrefetch: PrefetchConfig | undefined,
}

export function getPrefetchOption({ routerPrefetch, routePrefetch, linkPrefetch }: PrefetchConfigs, setting: keyof PrefetchConfigOptions): boolean {
return getPrefetchConfigValue(linkPrefetch, setting) ?? getPrefetchConfigValue(routePrefetch, setting) ?? getPrefetchConfigValue(routerPrefetch, setting) ?? DEFAULT_PREFETCH_CONFIG[setting]
}

export function getPrefetchConfigValue(prefetch: PrefetchConfig | undefined, setting: keyof PrefetchConfigOptions): boolean | undefined {
if (isRecord(prefetch)) {
return prefetch[setting]
}

return prefetch
}
Loading
Loading