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