diff --git a/client.d.ts b/client.d.ts
index 4c055f528..8b4dd7e8a 100644
--- a/client.d.ts
+++ b/client.d.ts
@@ -42,6 +42,7 @@ declare module 'vue-router/auto' {
// Experimental Data Fetching
definePage,
DataLoaderPlugin,
+ preloadRoute,
NavigationResult,
} from 'unplugin-vue-router/runtime'
// must be added to the virtual vue-router/auto
diff --git a/playground/src/App.vue b/playground/src/App.vue
index 0878405d5..fa1ed2575 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -7,6 +7,7 @@ import type {
} from 'vue-router'
import { ref } from 'vue'
import { routes } from 'vue-router/auto-routes'
+import { preloadRoute } from 'unplugin-vue-router/runtime'
console.log(`We have ${routes.length} routes.`)
@@ -105,6 +106,12 @@ function _test() {
+
diff --git a/playground/src/pages/[name].vue b/playground/src/pages/[name].vue
index 1631b08b9..387513b8a 100644
--- a/playground/src/pages/[name].vue
+++ b/playground/src/pages/[name].vue
@@ -5,6 +5,7 @@ export const useUserData = defineBasicLoader(
'/[name]',
async (route) => {
await delay(1000)
+ console.log('useUserData called for', route.params.name)
if (route.name === '/[name]') {
route.params
}
diff --git a/src/codegen/vueRouterModule.ts b/src/codegen/vueRouterModule.ts
index b153a9141..2e6b89158 100644
--- a/src/codegen/vueRouterModule.ts
+++ b/src/codegen/vueRouterModule.ts
@@ -16,6 +16,7 @@ export { definePage } from 'unplugin-vue-router/runtime'
export {
DataLoaderPlugin,
NavigationResult,
+ preloadRoute,
} from 'unplugin-vue-router/data-loaders'
export * from 'unplugin-vue-router/data-loaders/basic'
diff --git a/src/data-loaders/entries/index.ts b/src/data-loaders/entries/index.ts
index c5a1f30e3..1f8ac219c 100644
--- a/src/data-loaders/entries/index.ts
+++ b/src/data-loaders/entries/index.ts
@@ -1,6 +1,7 @@
export {
DataLoaderPlugin,
NavigationResult,
+ preloadRoute,
withLoaderContext,
} from 'unplugin-vue-router/runtime'
export type {
diff --git a/src/data-loaders/index.ts b/src/data-loaders/index.ts
index 5bbe06e32..605b7821a 100644
--- a/src/data-loaders/index.ts
+++ b/src/data-loaders/index.ts
@@ -10,7 +10,11 @@ export type {
} from './createDataLoader'
// new data fetching
-export { DataLoaderPlugin, NavigationResult } from './navigation-guard'
+export {
+ DataLoaderPlugin,
+ NavigationResult,
+ preloadRoute,
+} from './navigation-guard'
export type {
DataLoaderPluginOptions,
SetupLoaderGuardOptions,
diff --git a/src/data-loaders/meta-extensions.ts b/src/data-loaders/meta-extensions.ts
index 9d61a70be..8c065d4b9 100644
--- a/src/data-loaders/meta-extensions.ts
+++ b/src/data-loaders/meta-extensions.ts
@@ -1,7 +1,8 @@
-import { type App } from 'vue'
+import type { App, EffectScope } from 'vue'
import type { DataLoaderEntryBase, UseDataLoader } from './createDataLoader'
import type {
APP_KEY,
+ EFFECT_SCOPE_KEY,
LOADER_ENTRIES_KEY,
LOADER_SET_KEY,
PENDING_LOCATION_KEY,
@@ -46,6 +47,8 @@ declare module 'vue-router' {
[APP_KEY]: App
+ [EFFECT_SCOPE_KEY]: EffectScope
+
[IS_SSR_KEY]: boolean
}
diff --git a/src/data-loaders/navigation-guard.spec.ts b/src/data-loaders/navigation-guard.spec.ts
index 38d40e4c6..3a3855eee 100644
--- a/src/data-loaders/navigation-guard.spec.ts
+++ b/src/data-loaders/navigation-guard.spec.ts
@@ -16,6 +16,7 @@ import { getRouter } from 'vue-router-mock'
import {
ABORT_CONTROLLER_KEY,
LOADER_SET_KEY,
+ preloadRoute,
setCurrentContext,
DataLoaderPlugin,
NavigationResult,
@@ -470,4 +471,99 @@ describe('navigation-guard', () => {
expect(router.currentRoute.value.fullPath).toBe('/#ok')
})
})
+
+ describe('preloadRoute', () => {
+ it('collects loaders from the matched route', async () => {
+ setupApp(false)
+ const router = getRouter()
+ router.addRoute({
+ name: '_test',
+ path: '/fetch',
+ component,
+ meta: {
+ loaders: [loader1, loader1], // duplicated on purpose
+ },
+ })
+ router.addRoute({
+ name: '_test2',
+ path: '/fetch2',
+ component,
+ meta: {
+ loaders: [loader2, loader3],
+ },
+ })
+ await preloadRoute(router, '/fetch')
+ let set = router.resolve('/fetch').meta[LOADER_SET_KEY]
+ expect([...set!]).toEqual([loader1])
+ await preloadRoute(router, '/fetch2')
+ set = router.resolve('/fetch2').meta[LOADER_SET_KEY]
+ expect([...set!]).toEqual([loader2, loader3])
+ })
+
+ it.todo('collects loaders from nested routes', async () => {
+ setupApp(false)
+ const router = getRouter()
+ router.addRoute({
+ name: '_test',
+ path: '/fetch',
+ component,
+ meta: {
+ loaders: [loader1],
+ },
+ children: [
+ {
+ name: '_test2',
+ path: 'nested',
+ component,
+ meta: {
+ loaders: [loader2, loader3],
+ },
+ },
+ ],
+ })
+ await preloadRoute(router, '/fetch/nested')
+ const set = router.resolve('/fetch/nested').meta[LOADER_SET_KEY]
+ // TODO: fix parent loader (loader1) not being collected
+ expect([...set!]).toEqual([loader1, loader2, loader3])
+ })
+
+ it.todo('collects all loaders from lazy loaded pages', async () => {
+ setupApp(false)
+ const router = getRouter()
+ router.addRoute({
+ name: '_test',
+ path: '/fetch',
+ component: () =>
+ import('../../tests/data-loaders/ComponentWithLoader.vue'),
+ })
+ await preloadRoute(router, '/fetch')
+ const set = router.resolve('/fetch').meta[LOADER_SET_KEY]
+ expect([...set!]).toEqual([useDataOne, useDataTwo])
+ })
+
+ it('resolves all loaders', async () => {
+ setupApp(false)
+ const router = getRouter()
+ const l1 = mockedLoader()
+ const l2 = mockedLoader()
+ router.addRoute({
+ name: '_test',
+ path: '/fetch',
+ component,
+ meta: {
+ loaders: [l1.loader, l2.loader],
+ },
+ })
+
+ let isPreloaded = false
+ preloadRoute(router, '/fetch').then(() => (isPreloaded = true))
+ await vi.runAllTimersAsync()
+ l1.resolve()
+ await vi.runAllTimersAsync()
+ expect(isPreloaded).toBeFalsy()
+ l2.resolve()
+ await vi.runAllTimersAsync()
+ expect(isPreloaded).toBeTruthy()
+ })
+ })
})
diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts
index 1431ffd66..c7b6497bc 100644
--- a/src/data-loaders/navigation-guard.ts
+++ b/src/data-loaders/navigation-guard.ts
@@ -1,8 +1,9 @@
-import { isNavigationFailure } from 'vue-router'
+import { isNavigationFailure, loadRouteLocation } from 'vue-router'
import { effectScope, type App, type EffectScope } from 'vue'
import {
ABORT_CONTROLLER_KEY,
APP_KEY,
+ EFFECT_SCOPE_KEY,
IS_SSR_KEY,
LOADER_ENTRIES_KEY,
LOADER_SET_KEY,
@@ -14,6 +15,7 @@ import type {
NavigationGuard,
NavigationGuardReturn,
RouteLocationNormalizedLoaded,
+ RouteLocationRaw,
Router,
} from 'vue-router'
import { type _Awaitable } from '../utils'
@@ -61,6 +63,9 @@ export function setupLoaderGuard({
// Access to `app.runWithContext()`
router[APP_KEY] = app
+ // Access to shared effect scope
+ router[EFFECT_SCOPE_KEY] = effect
+
router[IS_SSR_KEY] = !!isSSR
// guard to add the loaders to the meta property
@@ -72,117 +77,22 @@ export function setupLoaderGuard({
router[PENDING_LOCATION_KEY].meta[ABORT_CONTROLLER_KEY]?.abort()
}
- // global pending location, used by nested loaders to know if they should load or not
- router[PENDING_LOCATION_KEY] = to
- // Differently from records, this one is reset on each navigation
- // so it must be built each time
- to.meta[LOADER_SET_KEY] = new Set()
- // adds an abort controller that can pass a signal to loaders
- to.meta[ABORT_CONTROLLER_KEY] = new AbortController()
- // allow loaders to add navigation results
- to.meta[NAVIGATION_RESULTS_KEY] = []
-
- // Collect all the lazy loaded components to await them in parallel
- const lazyLoadingPromises: Promise[] = []
-
- for (const record of to.matched) {
- // we only need to do this once per record as these changes are preserved
- // by the router
- if (!record.meta[LOADER_SET_KEY]) {
- // setup an empty array to skip the check next time
- record.meta[LOADER_SET_KEY] = new Set(record.meta.loaders || [])
-
- // add all the loaders from the components to the set
- for (const componentName in record.components) {
- const component: unknown = record.components[componentName]
-
- // we only add async modules because otherwise the component doesn't have any loaders and the user should add
- // them with the `loaders` array
- if (isAsyncModule(component)) {
- const promise = component().then(
- (viewModule: Record) => {
- for (const exportName in viewModule) {
- const exportValue = viewModule[exportName]
-
- if (isDataLoader(exportValue)) {
- record.meta[LOADER_SET_KEY]!.add(exportValue)
- }
- }
- }
- )
-
- lazyLoadingPromises.push(promise)
- }
- }
- }
- }
-
- return Promise.all(lazyLoadingPromises).then(() => {
- // group all the loaders in a single set
- for (const record of to.matched) {
- // merge the whole set of loaders
- for (const loader of record.meta[LOADER_SET_KEY]!) {
- to.meta[LOADER_SET_KEY]!.add(loader)
- }
- }
- // we return nothing to remove the value to allow the navigation
- // same as return true
- })
+ return collectLoaders(router, to)
})
const removeDataLoaderGuard = router.beforeResolve((to) => {
// if we reach this guard, all properties have been set
const loaders = Array.from(to.meta[LOADER_SET_KEY]!) as UseDataLoader[]
- // TODO: could we benefit anywhere here from verifying the signal is aborted and not call the loaders at all
- // if (to.meta[ABORT_CONTROLLER_KEY]!.signal.aborted) {
- // return to.meta[ABORT_CONTROLLER_KEY]!.signal.reason ?? false
- // }
-
- // unset the context so all loaders are executed as root loaders
- setCurrentContext([])
- return Promise.all(
- loaders.map((loader) => {
- const { server, lazy } = loader._.options
- // do not run on the server if specified
- if (!server && isSSR) {
- return
- }
- // keep track of loaders that should be committed after all loaders are done
- const ret = effect.run(() =>
- app
- // allows inject and provide APIs
- .runWithContext(() =>
- loader._.load(to as RouteLocationNormalizedLoaded, router)
- )
- )!
-
- // on client-side, lazy loaders are not awaited, but on server they are
- // we already checked for the `server` option above
- return !isSSR && lazy
- ? undefined
- : // return the non-lazy loader to commit changes after all loaders are done
- ret
- })
- ) // let the navigation go through by returning true or void
- .then(() => {
- // console.log(
- // `✨ Navigation results "${to.fullPath}": [${to.meta[
- // NAVIGATION_RESULTS_KEY
- // ]!.map((r) => JSON.stringify(r.value)).join(', ')}]`
- // )
- if (to.meta[NAVIGATION_RESULTS_KEY]!.length) {
- return selectNavigationResult(to.meta[NAVIGATION_RESULTS_KEY]!)
- }
- })
- .catch((error) =>
- error instanceof NavigationResult
- ? error.value
- : // let the error propagate to router.onError()
- // we use never because the rejection means we never resolve a value and using anything else
- // will not be valid from the navigation guard's perspective
- Promise.reject(error)
- )
+ return executeLoaders({
+ app,
+ router,
+ loaders,
+ to,
+ effect,
+ isSSR,
+ selectNavigationResult,
+ })
})
// listen to duplicated navigation failures to reset the pendingTo and pendingLoad
@@ -269,6 +179,154 @@ export function isAsyncModule(
)
}
+export function collectLoaders(
+ router: Router,
+ to: RouteLocationNormalizedLoaded
+) {
+ // global pending location, used by nested loaders to know if they should load or not
+ router[PENDING_LOCATION_KEY] = to
+ // Differently from records, this one is reset on each navigation
+ // so it must be built each time
+ to.meta[LOADER_SET_KEY] = new Set()
+ // adds an abort controller that can pass a signal to loaders
+ to.meta[ABORT_CONTROLLER_KEY] = new AbortController()
+ // allow loaders to add navigation results
+ to.meta[NAVIGATION_RESULTS_KEY] = []
+
+ // Collect all the lazy loaded components to await them in parallel
+ const lazyLoadingPromises: Promise[] = []
+
+ for (const record of to.matched) {
+ // we only need to do this once per record as these changes are preserved
+ // by the router
+ if (!record.meta[LOADER_SET_KEY]) {
+ // setup an empty array to skip the check next time
+ record.meta[LOADER_SET_KEY] = new Set(record.meta.loaders || [])
+
+ // add all the loaders from the components to the set
+ for (const componentName in record.components) {
+ const component: unknown = record.components[componentName]
+
+ // we only add async modules because otherwise the component doesn't have any loaders and the user should add
+ // them with the `loaders` array
+ if (isAsyncModule(component)) {
+ const promise = component().then(
+ (viewModule: Record) => {
+ for (const exportName in viewModule) {
+ const exportValue = viewModule[exportName]
+
+ if (isDataLoader(exportValue)) {
+ record.meta[LOADER_SET_KEY]!.add(exportValue)
+ }
+ }
+ }
+ )
+
+ lazyLoadingPromises.push(promise)
+ }
+ }
+ }
+ }
+
+ return Promise.all(lazyLoadingPromises).then(() => {
+ // group all the loaders in a single set
+ for (const record of to.matched) {
+ // merge the whole set of loaders
+ for (const loader of record.meta[LOADER_SET_KEY]!) {
+ to.meta[LOADER_SET_KEY]!.add(loader)
+ }
+ }
+ // we return nothing to remove the value to allow the navigation
+ // same as return true
+ })
+}
+
+export function executeLoaders({
+ app,
+ loaders,
+ to,
+ router,
+ effect,
+ isSSR,
+ selectNavigationResult,
+}: {
+ app: App
+ router: Router
+ loaders: UseDataLoader[]
+ to: RouteLocationNormalizedLoaded
+ effect: EffectScope
+ isSSR?: boolean
+ selectNavigationResult?: DataLoaderPluginOptions['selectNavigationResult']
+}) {
+ // TODO: could we benefit anywhere here from verifying the signal is aborted and not call the loaders at all
+ // if (to.meta[ABORT_CONTROLLER_KEY]!.signal.aborted) {
+ // return to.meta[ABORT_CONTROLLER_KEY]!.signal.reason ?? false
+ // }
+
+ // unset the context so all loaders are executed as root loaders
+ setCurrentContext([])
+ return Promise.all(
+ loaders.map((loader) => {
+ const { server, lazy } = loader._.options
+ // do not run on the server if specified
+ if (!server && isSSR) {
+ return
+ }
+ // keep track of loaders that should be committed after all loaders are done
+ const ret = effect.run(() =>
+ app
+ // allows inject and provide APIs
+ .runWithContext(() =>
+ loader._.load(to as RouteLocationNormalizedLoaded, router)
+ )
+ )!
+
+ // on client-side, lazy loaders are not awaited, but on server they are
+ // we already checked for the `server` option above
+ return !isSSR && lazy
+ ? undefined
+ : // return the non-lazy loader to commit changes after all loaders are done
+ ret
+ })
+ ) // let the navigation go through by returning true or void
+ .then(() => {
+ // console.log(
+ // `✨ Navigation results "${to.fullPath}": [${to.meta[
+ // NAVIGATION_RESULTS_KEY
+ // ]!.map((r) => JSON.stringify(r.value)).join(', ')}]`
+ // )
+ if (selectNavigationResult && to.meta[NAVIGATION_RESULTS_KEY]!.length) {
+ return selectNavigationResult(to.meta[NAVIGATION_RESULTS_KEY]!)
+ }
+ })
+ .catch((error) =>
+ error instanceof NavigationResult
+ ? error.value
+ : // let the error propagate to router.onError()
+ // we use never because the rejection means we never resolve a value and using anything else
+ // will not be valid from the navigation guard's perspective
+ Promise.reject(error)
+ )
+}
+
+export async function preloadRoute(router: Router, route: RouteLocationRaw) {
+ const resolvedRoute = router.resolve(route)
+ const loadedRoute = await loadRouteLocation(resolvedRoute)
+ await collectLoaders(router, loadedRoute)
+
+ const loaders = Array.from(
+ loadedRoute.meta[LOADER_SET_KEY]!
+ ) as UseDataLoader[]
+
+ return executeLoaders({
+ to: loadedRoute,
+ router,
+ loaders,
+ app: router[APP_KEY],
+ effect: router[EFFECT_SCOPE_KEY],
+ })
+}
+
/**
* Options to initialize the data loader guard.
*/
diff --git a/src/data-loaders/symbols.ts b/src/data-loaders/symbols.ts
index 10bced1f9..235a84056 100644
--- a/src/data-loaders/symbols.ts
+++ b/src/data-loaders/symbols.ts
@@ -35,6 +35,12 @@ export const STAGED_NO_VALUE = Symbol()
*/
export const APP_KEY = Symbol()
+/**
+ * Gives access to the effect scope used for the data loaders.
+ * @internal
+ */
+export const EFFECT_SCOPE_KEY = Symbol()
+
/**
* Gives access to an AbortController that aborts when the navigation is canceled.
* @internal