Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/data-loaders/meta-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -70,6 +71,12 @@ declare module 'vue-router' {
*/
[LOADER_SET_KEY]?: Set<UseDataLoader>

/**
* List of promises while loaders from async components are being collected.
* @internal
*/
[LOADER_SET_PROMISES_KEY]?: Promise<void>[]

/**
* The signal that is aborted when the navigation is canceled or an error occurs.
* @internal
Expand Down
73 changes: 73 additions & 0 deletions src/data-loaders/navigation-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
NavigationResult,
DataLoaderPluginOptions,
useIsDataLoading,
LOADER_SET_PROMISES_KEY,
} from 'unplugin-vue-router/data-loaders'
import { mockPromise } from '../../tests/utils'
import {
Expand Down Expand Up @@ -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<void>()

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()
Expand Down
39 changes: 29 additions & 10 deletions src/data-loaders/navigation-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions src/data-loaders/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down