Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add api for preloading route loaders #435

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.`)

Expand Down Expand Up @@ -105,6 +106,12 @@ function _test() {
<input type="text" v-model="targetRoute" />
</label>
<button>Go</button>
<button
type="button"
@click.prevent="preloadRoute(router, targetRoute)"
>
Preload
</button>
</form>
</div>
</header>
Expand Down
1 change: 1 addition & 0 deletions playground/src/pages/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions src/codegen/vueRouterModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/data-loaders/entries/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
DataLoaderPlugin,
NavigationResult,
preloadRoute,
withLoaderContext,
} from 'unplugin-vue-router/runtime'
export type {
Expand Down
6 changes: 5 additions & 1 deletion src/data-loaders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
262 changes: 157 additions & 105 deletions src/data-loaders/navigation-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
NavigationGuard,
NavigationGuardReturn,
RouteLocationNormalizedLoaded,
RouteLocationRaw,
Router,
} from 'vue-router'
import { type _Awaitable } from '../utils'
Expand Down Expand Up @@ -72,117 +73,22 @@
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<unknown>[] = []

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<string, unknown>) => {
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<never>(error)
)
return executeLoaders({
app,
router,
loaders,
to,
effect,
isSSR,
selectNavigationResult,
})
})

// listen to duplicated navigation failures to reset the pendingTo and pendingLoad
Expand Down Expand Up @@ -269,6 +175,152 @@
)
}

export function collectLoaders(
router: Router,
to: RouteLocationNormalizedLoaded
) {
console.log(to)
// 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<unknown>[] = []

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<string, unknown>) => {
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 = (results) => results[0]!.value,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default value shouldn't be needed here

}: {
app: App<unknown>
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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm realizing I should be able to fix the need for this cast in Vue 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<never>(error)
)
}

export async function preloadRoute(router: Router, route: RouteLocationRaw) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this function wouldn't actually want to live here eventually but I just wanted to keep it close while I started investigating for simplicity.

const _route = router.resolve(route)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess there's a way to access the router without having to pass it in like this? useRouter didn't seem to do the trick.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in a function like this. Passing the router instance is totally fine

await collectLoaders(router, _route)

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / lint

Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'.

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, ubuntu-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, windows-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, macos-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, ubuntu-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, windows-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32

Check failure on line 311 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, macos-latest)

Unhandled error

TypeCheckError: Argument of type 'RouteLocationResolvedGeneric' is not assignable to parameter of type 'RouteLocationNormalizedLoadedGeneric'. Types of property 'name' are incompatible. Type 'RouteRecordNameGeneric | null' is not assignable to type 'RouteRecordNameGeneric'. Type 'null' is not assignable to type 'RouteRecordNameGeneric'. ❯ src/data-loaders/navigation-guard.ts:311:32
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise the route types here don't match, but I'm still not sure how something goes from eg RouteLocationResolvedGeneric to RouteLocationNormalizedLoadedGeneric.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Loaded suffix here means that the components field in the route record is loaded and that all values are components that can be rendered. There is a function in vue-router loadRouteLocation() that allows to ensure this


const loaders = Array.from(_route.meta[LOADER_SET_KEY]!) as UseDataLoader[]

return executeLoaders({
to: _route,

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / lint

Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'.

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, ubuntu-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, windows-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (18.x, macos-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, ubuntu-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, windows-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5

Check failure on line 316 in src/data-loaders/navigation-guard.ts

View workflow job for this annotation

GitHub Actions / test (lts/*, macos-latest)

Unhandled error

TypeCheckError: Type 'RouteLocationResolvedGeneric' is not assignable to type 'RouteLocationNormalizedLoadedGeneric'. ❯ src/data-loaders/navigation-guard.ts:316:5
router,
loaders,
app: router[APP_KEY],
effect: effectScope(),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect must be the one created within the DataLoaderPlugin. It should probably be added to the router with a symbol like other properties

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have done this now

})
}

/**
* Options to initialize the data loader guard.
*/
Expand Down
Loading