diff --git a/src/data-loaders/entries/index.ts b/src/data-loaders/entries/index.ts index 44e7b88c2..142d633cf 100644 --- a/src/data-loaders/entries/index.ts +++ b/src/data-loaders/entries/index.ts @@ -13,7 +13,11 @@ export type { export { toLazyValue } from '../createDataLoader' // new data fetching -export { DataLoaderPlugin, NavigationResult } from '../navigation-guard' +export { + DataLoaderPlugin, + NavigationResult, + useIsDataLoading, +} from '../navigation-guard' export type { DataLoaderPluginOptions, SetupLoaderGuardOptions, diff --git a/src/data-loaders/navigation-guard.spec.ts b/src/data-loaders/navigation-guard.spec.ts index 4675df2a6..b9d902446 100644 --- a/src/data-loaders/navigation-guard.spec.ts +++ b/src/data-loaders/navigation-guard.spec.ts @@ -24,6 +24,7 @@ import { DataLoaderPlugin, NavigationResult, DataLoaderPluginOptions, + useIsDataLoading, } from 'unplugin-vue-router/data-loaders' import { mockPromise } from '../../tests/utils' import { @@ -339,6 +340,35 @@ describe('navigation-guard', () => { 'does not call commit for a loader if the navigation is canceled by another loader' ) + it('sets isDataLoading within a navigation', async () => { + const { app } = setupApp({ isSSR: false }) + const isGloballyLoading = app.runWithContext(() => useIsDataLoading()) + expect(isGloballyLoading.value).toBe(false) + + const router = getRouter() + const l1 = mockedLoader() + const l2 = mockedLoader() + router.addRoute({ + name: '_test', + path: '/fetch', + component, + meta: { + loaders: [l1.loader, l2.loader], + }, + }) + + router.push('/fetch') + await vi.runOnlyPendingTimersAsync() + expect(isGloballyLoading.value).toBe(true) + + l1.resolve() + await vi.runAllTimersAsync() + expect(isGloballyLoading.value).toBe(true) + l2.resolve() + await vi.runAllTimersAsync() + expect(isGloballyLoading.value).toBe(false) + }) + describe('signal', () => { it('aborts the signal if the navigation throws', async () => { setupApp({ isSSR: false }) diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts index 076b09f20..a34b89581 100644 --- a/src/data-loaders/navigation-guard.ts +++ b/src/data-loaders/navigation-guard.ts @@ -1,5 +1,13 @@ import { isNavigationFailure } from 'vue-router' -import { effectScope, type App, type EffectScope } from 'vue' +import { + effectScope, + inject, + shallowRef, + type InjectionKey, + type ShallowRef, + type App, + type EffectScope, +} from 'vue' import { ABORT_CONTROLLER_KEY, APP_KEY, @@ -19,6 +27,12 @@ import type { import { type _Awaitable } from '../utils' import { toLazyValue, type UseDataLoader } from './createDataLoader' +/** + * Key to inject the global loading state for loaders used in `useIsDataLoading`. + * @internal + */ +export const IS_DATA_LOADING_KEY = Symbol() as InjectionKey> + /** * TODO: export functions that allow preloading outside of a navigation guard */ @@ -34,7 +48,7 @@ import { toLazyValue, type UseDataLoader } from './createDataLoader' export function setupLoaderGuard({ router, app, - effect, + effect: scope, isSSR, errors: globalErrors = [], selectNavigationResult = (results) => results[0]!.value, @@ -64,6 +78,10 @@ export function setupLoaderGuard({ router[IS_SSR_KEY] = !!isSSR + // global loading state for loaders used in `useIsDataLoading` + const isDataLoading = scope.run(() => shallowRef(false))! + app.provide(IS_DATA_LOADING_KEY, isDataLoading) + // guard to add the loaders to the meta property const removeLoaderGuard = router.beforeEach((to) => { // Abort any pending navigation. For cancelled navigations, this will happen before the `router.afterEach()` @@ -156,6 +174,9 @@ export function setupLoaderGuard({ // unset the context so all loaders are executed as root loaders setCurrentContext([]) + + isDataLoading.value = true + return Promise.all( loaders.map((loader) => { const { server, lazy, errors } = loader._.options @@ -164,7 +185,7 @@ export function setupLoaderGuard({ return } // keep track of loaders that should be committed after all loaders are done - const ret = effect.run(() => + const ret = scope.run(() => app // allows inject and provide APIs .runWithContext(() => @@ -225,6 +246,7 @@ export function setupLoaderGuard({ // unset the context so mounting happens without an active context // and loaders do not believe they are being called as nested when they are not setCurrentContext([]) + isDataLoading.value = false }) }) @@ -414,3 +436,11 @@ export interface DataLoaderPluginOptions { */ errors?: Array any> | ((reason?: unknown) => boolean) } + +/** + * Return a ref that reflects the global loading state of all loaders within a navigation. + * This state doesn't update if `refresh()` is manually called. + */ +export function useIsDataLoading() { + return inject(IS_DATA_LOADING_KEY)! +}