diff --git a/src/data-loaders/meta-extensions.ts b/src/data-loaders/meta-extensions.ts index 5835aa53c..4954d18b3 100644 --- a/src/data-loaders/meta-extensions.ts +++ b/src/data-loaders/meta-extensions.ts @@ -8,6 +8,7 @@ import type { ABORT_CONTROLLER_KEY, NAVIGATION_RESULTS_KEY, IS_SSR_KEY, + LOADER_SET_PROMISES_KEY, } from './symbols' import { type NavigationResult } from './navigation-guard' @@ -70,6 +71,12 @@ declare module 'vue-router' { */ [LOADER_SET_KEY]?: Set + /** + * List of promises while loaders from async components are being collected. + * @internal + */ + [LOADER_SET_PROMISES_KEY]?: Promise[] + /** * The signal that is aborted when the navigation is canceled or an error occurs. * @internal diff --git a/src/data-loaders/navigation-guard.spec.ts b/src/data-loaders/navigation-guard.spec.ts index b9d902446..a43962a29 100644 --- a/src/data-loaders/navigation-guard.spec.ts +++ b/src/data-loaders/navigation-guard.spec.ts @@ -25,6 +25,7 @@ import { NavigationResult, DataLoaderPluginOptions, useIsDataLoading, + LOADER_SET_PROMISES_KEY, } from 'unplugin-vue-router/data-loaders' import { mockPromise } from '../../tests/utils' import { @@ -178,6 +179,78 @@ describe('navigation-guard', () => { expect([...set!]).toEqual([useDataOne, useDataTwo]) }) + it('collects all loaders from lazy loaded pages with repeated navigation', async () => { + setupApp({ isSSR: false }) + const router = getRouter() + + const { promise, resolve } = Promise.withResolvers() + + router.addRoute({ + name: '_test', + path: '/fetch', + component: async () => { + await promise + + return import('../../tests/data-loaders/ComponentWithLoader.vue') + }, + }) + + void router.push('/fetch') + + // wait a tick to ensure first navigation is started + await Promise.resolve() + + const secondNavPromise = router.push('/fetch') + resolve() + await secondNavPromise + + const set = router.currentRoute.value.meta[LOADER_SET_KEY] + expect([...set!]).toEqual([useDataOne, useDataTwo]) + + for (const record of router.currentRoute.value.matched) { + expect(record.meta[LOADER_SET_PROMISES_KEY]).toBeUndefined() + } + }) + + it('collects all loaders from lazy loaded pages when first navigation fails', async () => { + setupApp({ isSSR: false }) + const router = getRouter() + + let first = true + router.addRoute({ + name: '_test', + path: '/fetch', + component: async () => { + if (first) { + first = false + + throw new Error('Failed to load component') + } + + return import('../../tests/data-loaders/ComponentWithLoader.vue') + }, + }) + + const firstNavPromise = router.push('/fetch') + await expect(firstNavPromise).rejects.toThrow(Error) + + // Verify loaders collection cleanup after failure + const rec = getRouter() + .getRoutes() + .find((r) => r.name === '_test')! + expect(rec.meta[LOADER_SET_KEY]).toBeUndefined() + expect(rec.meta[LOADER_SET_PROMISES_KEY]).toBeUndefined() + + await router.push('/fetch') + + const set = router.currentRoute.value.meta[LOADER_SET_KEY] + expect([...set!]).toEqual([useDataOne, useDataTwo]) + + for (const record of router.currentRoute.value.matched) { + expect(record.meta[LOADER_SET_PROMISES_KEY]).toBeUndefined() + } + }) + it('awaits for all loaders to be resolved', async () => { setupApp({ isSSR: false }) const router = getRouter() diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts index a34b89581..669d7dc9a 100644 --- a/src/data-loaders/navigation-guard.ts +++ b/src/data-loaders/navigation-guard.ts @@ -14,6 +14,7 @@ import { IS_SSR_KEY, LOADER_ENTRIES_KEY, LOADER_SET_KEY, + LOADER_SET_PROMISES_KEY, NAVIGATION_RESULTS_KEY, PENDING_LOCATION_KEY, } from './meta-extensions' @@ -145,22 +146,40 @@ export function setupLoaderGuard({ } }) + record.meta[LOADER_SET_PROMISES_KEY] ??= [] + record.meta[LOADER_SET_PROMISES_KEY].push(promise) lazyLoadingPromises.push(promise) } + } else if (record.meta[LOADER_SET_PROMISES_KEY]) { + // When repeated navigation happen on the same route, loaders might still be + // loading from async components, so we need to wait for them to resolve. + lazyLoadingPromises.push(...record.meta[LOADER_SET_PROMISES_KEY]) } } - 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) + 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) + } + record.meta[LOADER_SET_PROMISES_KEY] = undefined } - } - // we return nothing to remove the value to allow the navigation - // same as return true - }) + // we return nothing to remove the value to allow the navigation + // same as return true + }) + .catch((error) => { + // If error happens while collecting loaders, reset them + // so on next navigation we can try again + for (const record of to.matched) { + record.meta[LOADER_SET_KEY] = undefined + record.meta[LOADER_SET_PROMISES_KEY] = undefined + } + + throw error + }) }) const removeDataLoaderGuard = router.beforeResolve((to, from) => { diff --git a/src/data-loaders/symbols.ts b/src/data-loaders/symbols.ts index 10bced1f9..1516fbe28 100644 --- a/src/data-loaders/symbols.ts +++ b/src/data-loaders/symbols.ts @@ -4,6 +4,12 @@ */ export const LOADER_SET_KEY = Symbol('loaders') +/** + * Retrieves promises for loaders which are still being collected. + * @internal + */ +export const LOADER_SET_PROMISES_KEY = Symbol('loadersPromise') + /** * Retrieves the internal version of loader entries. * @internal