From 1fe9c8a10959fb9de5f9889f56ac30e833535e2f Mon Sep 17 00:00:00 2001 From: Justinas Delinda <8914032+minht11@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:58:56 +0300 Subject: [PATCH 1/2] fix: loaders not being collected from async components for repeated navigations fix LOADER_SET_PROMISES_KEY being not reset --- src/data-loaders/meta-extensions.ts | 7 +++++++ src/data-loaders/navigation-guard.spec.ts | 25 +++++++++++++++++++++++ src/data-loaders/navigation-guard.ts | 9 ++++++++ src/data-loaders/symbols.ts | 6 ++++++ 4 files changed, 47 insertions(+) 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..bcf7b0be6 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,30 @@ 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() + router.addRoute({ + name: '_test', + path: '/fetch', + component: () => + import('../../tests/data-loaders/ComponentWithLoader.vue'), + }) + + void router.push('/fetch') + + // simulate repeated navigation while the async component is loading + await Promise.resolve() + 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..3eaa21e36 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,8 +146,15 @@ 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]) { + console.log('REUSE', record.path) + // 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]) } } @@ -157,6 +165,7 @@ export function setupLoaderGuard({ 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 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 From 4e90e602a7ef16d7896ddff972fdfc3ae533ba75 Mon Sep 17 00:00:00 2001 From: Justinas Delinda <8914032+minht11@users.noreply.github.com> Date: Sat, 6 Sep 2025 00:19:36 +0300 Subject: [PATCH 2/2] handle error while collecting loaders improve tests handle error while collecting loaders --- src/data-loaders/navigation-guard.spec.ts | 54 +++++++++++++++++++++-- src/data-loaders/navigation-guard.ts | 34 +++++++++----- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/data-loaders/navigation-guard.spec.ts b/src/data-loaders/navigation-guard.spec.ts index bcf7b0be6..a43962a29 100644 --- a/src/data-loaders/navigation-guard.spec.ts +++ b/src/data-loaders/navigation-guard.spec.ts @@ -182,17 +182,65 @@ describe('navigation-guard', () => { 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: () => - import('../../tests/data-loaders/ComponentWithLoader.vue'), + component: async () => { + await promise + + return import('../../tests/data-loaders/ComponentWithLoader.vue') + }, }) void router.push('/fetch') - // simulate repeated navigation while the async component is loading + // 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] diff --git a/src/data-loaders/navigation-guard.ts b/src/data-loaders/navigation-guard.ts index 3eaa21e36..669d7dc9a 100644 --- a/src/data-loaders/navigation-guard.ts +++ b/src/data-loaders/navigation-guard.ts @@ -151,25 +151,35 @@ export function setupLoaderGuard({ lazyLoadingPromises.push(promise) } } else if (record.meta[LOADER_SET_PROMISES_KEY]) { - console.log('REUSE', record.path) // 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 } - 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) => {