diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md new file mode 100644 index 00000000..875c14c1 --- /dev/null +++ b/active-rfcs/0000-router-use-loader.md @@ -0,0 +1,985 @@ +- Start Date: 2022-07-14 +- Target Major Version: Vue 3, Vue Router 4 +- Reference Issues: +- Implementation PR: - + +# Todo List + +List of things that haven't been added to the document yet: + +- [x] ~~Show how to use the data loader without `vue-router/auto`~~ +- [x] ~~Explain what `vue-router/auto` brings~~ +- [ ] Extendable API for data fetching libraries like vue-apollo, vuefire, vue-query, etc + +# Summary + +There is no silver bullet to data fetching because of the different data fetching strategies and how they can define the architecture of the application and its UX. However, I think it's possible to find a solution that is flexible enough to **promote good practices** and **reduce the complexity** of data fetching in applications. +That is the goal of this RFC, to standardize and improve data fetching with vue-router: + +- Integrate data fetching to the navigation cycle (or not by making it _lazy/non-blocking_) +- Dedupe Requests +- Delay data updates until all data loaders are resolved +- Allow parallel or sequential data fetching (loaders that depends on the result of other loaders) +- Without needing Suspense to avoid cascading requests +- Provide control over loading/error states +- Define a set of Interfaces that enable other libraries like vue-apollo, vue-query, etc to implement their own loaders that can be used with the same API + + + +This proposal concerns Vue Router 4 and is implemented under [unplugin-vue-router](https://github.com/posva/unplugin-vue-router). Some features, like typed routes, are only available with file-based routing but this is not required. + +# Basic example + +We define loaders anywhere and attach them to **page components** (components associated to a route). They return a **composable that can be used in any component** (not only pages). + +A loader can be attached to a page in two ways: by being exported or by being added to the route definition. + +Exported from a non-setup ` + + +``` + +When a loader is exported by the page component, it is **automatically** picked up as long as the route is **lazy loaded** (which is a best practice). If the route isn't lazy loaded, the loader can be directly defined in an array of loaders on `meta.loaders`: + +```ts +import { createRouter } from 'vue-router' +import UserList from '@/pages/UserList.vue' +// could be anywhere +import { useUserList } from '@/loaders/users' + +export const router = createRouter({ + // ... + routes: [ + { + path: '/users', + component: UserList, + meta: { + // Required when the component is not lazy loaded + loaders: [useUserList] + } + }, + { + path: '/users/:id', + // automatically picks up all exported loaders + component: () => import('@/pages/UserDetails.vue') + } + ] +}) +``` + +- `user`, `pending`, and `error` are refs and therefore reactive. +- `refresh` is a function that can be called to force a refresh of the data without a new navigation. +- `useUserData()` can be used in **any component**, not only in the one that defines it. We import the function and call it within ` + + +``` + + + +## Usage outside of page components + +Loaders can be attached to a page even if the page component doesn't use it (invoke the composable returned by `defineLoader()`). It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, even by a parent. + +On top of that, loaders can be **defined anywhere** and imported where using the data makes sense. This allows to define loaders in a separate `src/loaders` folder and reuse them across pages: + +```ts +// src/loaders/user.ts +export const useUserData = defineLoader(...) +// ... +``` + +Ensure it is **exported** by page components: + +```vue + + + +``` + +You can still use it anywhere else: + +```vue + + +``` + +In such scenarios, it makes more sense to move the loader to a separate file to ensure better code splitting. + +## TypeScript + +Types are automatically generated for the routes by [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) and can be referenced with the name of each route to hint `defineLoader()` the possible values of the current types: + +```vue + + + +``` + +The arguments can be removed during the compilation step in production mode since they are only used for types and are ignored at runtime. + +## Non blocking data fetching (Lazy Loaders) + +Also known as [lazy async data in Nuxt](https://v3.nuxtjs.org/api/composables/use-async-data), loaders can be marked as lazy to **not block the navigation**. + +```vue + + + +``` + +This patterns is useful to avoid blocking the navigation while _less important data_ is being fetched. It will display the page earlier while some of the parts of it are still loading and you are able to display loader indicators thanks to the `pending` property. + +Note this still allows for having different behavior during SSR and client side navigation, e.g.: if we want to wait for the loader during SSR but not during client side navigation: + +```ts +export const useUserData = defineLoader( + async (route) => { + // ... + }, + { + lazy: !import.env.SSR, // Vite + lazy: process.client, // NuxtJS +) +``` + +Existing questions: + +- [~~Should it be possible to await all pending loaders with `await allPendingLoaders()`? Is it useful for SSR? Otherwise we could always ignore lazy loaders in SSR. Do we need both? Do we need to selectively await some of them?~~](https://github.com/vuejs/rfcs/discussions/460#discussioncomment-3532011) +- Should we be able to transform a loader into a lazy version of it: `const useUserDataLazy = asLazyLoader(useUserData)` + +## Controlling the navigation + +Since the data fetching happens within a navigation guard, it's possible to control the navigation like in regular navigation guards: + +- Thrown errors (or rejected Promises) cancel the navigation (same behavior as in a regular navigation guard) and are intercepted by [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) +- Redirection: `return new NavigationResult(targetLocation)` -> like `return targetLocation` in a regular navigation guard +- Cancelling the navigation: `return new NavigationResult(false)` like `return false` in a regular navigation guard + +```ts +import { NavigationResult } from 'vue-router' + +export const useUserData = defineLoader( + async ({ params, path ,query, hash }) => { + try { + const user = await getUserById(params.id) + + return user + } catch (error) { + if (error.status === 404) { + return new NavigationResult({ name: 'not-found', params: { pathMatch: } } + ) + } else { + throw error // aborts the vue router navigation + } + } + } +) +``` + +`new NavigationResult()` accepts as its only constructor argument, anything that [can be returned in a navigation guard](https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards). + +Some alternatives: + +- `createNavigationResult()`: too verbose +- `NavigationResult()` (no `new`): `NavigationResult` is not a primitive so it should use `new` + +The only difference between throwing an error and returning a `NavigationResult` of an error is that the latter will still trigger the [`selectNavigationResult()` mentioned right below](#handling-multiple-navigation-results) while a thrown error will always take the priority. + +### Handling multiple navigation results + +Since navigation loaders can run in parallel, they can return different navigation results as well. In this case, you can decide which result should be used by providing a `selectNavigationResult()` method to `setupLoaderGuard()`: + +```ts +setupLoaderGuard(router, { + selectNavigationResult(results) { + // results is an array of the unwrapped results passed to `new NavigationResult()` + return results.find((result) => result.name === 'not-found') + } +}) +``` + +`selectNavigationResult()` will be called with an array of the unwrapped results passed to `new NavigationResult()` **after all data loaders** have been resolved. **If any of them throws an error** or if none of them return a `NavigationResult`, `selectNavigationResult()` won't be called. + +### Eagerly changing the navigation + +If a loader wants to eagerly change the navigation, it can `throw` the `NavigationResult` instead of returning it. This will skip the `selectNavigationResult()` and take precedence. + +```ts +import { NavigationResult } from 'vue-router' + +export const useUserData = defineLoader( + async ({ params, path ,query, hash }) => { + try { + const user = await getUserById(params.id) + + return user + } catch (error) { + throw new NavigationResult( + { name: 'not-found', params: { pathMatch: } } + ) + } + } +) +``` + +## AbortSignal + +The loader receives in a second argument access to an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can be passed on to `fetch` and other Web APIs. If the navigation is cancelled because of errors or a new navigation, the signal aborts, causing any request using it abort as well. + +```ts +export const useBookCatalog = defineLoader(async (_route, { signal }) => { + const books = markRaw(await getBookCatalog({ signal })) + return books +}) +``` + +This aligns with the future [Navigation API](https://github.com/WICG/navigation-api#navigation-monitoring-and-interception) and other web APIs that use the `AbortSignal` to cancel an ongoing invocation. + +## SSR + +To support SSR we need to do two things: + +- A way to serialize each data loaded on the server with a unique _key_. Note: Would an array work? I don't think the order of execution is guaranteed. +- On the client side, pass the initial state to `setupLoaderGuard()`. The initial state is used once and discarded afterwards. + +Different implementations could have different kind of keys. The simplest form is a string: + +```ts +export const useBookCollection = defineLoader( + async () => { + const books = await fetchBookCollection() + return books + }, + { key: 'bookCollection' } +) +``` + +The configuration of `setupLoaderGuard()` depends on the SSR configuration, here is an example with vite-ssg: + +```ts +import { ViteSSG } from 'vite-ssg' +import { setupLoaderGuard } from 'vue-router' +import App from './App.vue' +import { routes } from './routes' + +export const createApp = ViteSSG( + App, + { routes }, + async ({ router, isClient, initialState }) => { + // fetchedData will be populated during navigation + const fetchedData = setupLoaderGuard(router, { + initialData: isClient + ? // on the client we pass the initial state + initialState.vueRouter + : // on server we want to generate the initial state + undefined + }) + + // on the server, we serialize the fetchedData + if (!isClient) { + initialState.vueRouter = fetchedData + } + } +) +``` + +Note that `setupLoaderGuard()` **should be called before `app.use(router)`** so it takes effect on the initial navigation. Otherwise a new navigation must be triggered after the navigation guard is added. + +### Avoiding double fetch on the client + +One of the advantages of having an initial state is that we can avoid fetching on the client, in fact, loaders are **completely skipped** on the client if the initial state is provided. This means nested loaders **aren't executed either**. Since data loaders shouldn't contain side effects besides data fetching, this shouldn't be a problem. Note that any loader **without a key** won't be serialized and will always be executed on both client and server. + + + +## Performance Tip + +**When fetching large data sets** that is never modified, it's convenient to mark the fetched data as _raw_ before returning it: + +```ts +export const useBookCatalog = defineLoader(async () => { + const books = markRaw(await getBookCatalog()) + return books +}) +``` + +[More in Vue docs](https://vuejs.org/api/reactivity-advanced.html#markraw) + +An alternative would be to internally use `shallowRef()` instead of `ref()` inside `defineLoader()` but that would prevent users from modifying the returned value and overall less convenient. Having to use `markRaw()` seems like a good trade off in terms of API and performance. + + + +## Global API + +It's possible to access a global state of when data loaders are fetching (during navigation or when `refresh()` is called) as well as when the data fetching navigation guard is running (only when navigating). + +- `isFetchingData: Ref`: is any loader currently fetching data? e.g. calling the `refresh()` method of a loader +- `isNavigationFetching: Ref`: is navigation being hold by a loader? (implies `isFetchingData.value === true`). Calling the `refresh()` method of a loader doesn't change this state. + +TBD: is this worth it? Are any other functions needed? + +## Limitations + +- ~~Injections (`inject`/`provide`) cannot be used within a loader~~ They can now +- Watchers and other composables shouldn't be used within data loaders: + - if `await` is used before calling a composable e.g. `watch()`, the scope **is not guaranteed** + - In practice, **this shouldn't be a problem** because there is **no need** to create composables within a loader + +# Drawbacks + +- At first, it looks less intuitive than just awaiting something inside `setup()` with `` [but it doesn't have its limitations](#limitations) +- Requires an extra ` +``` + +Or when params are involved in the data fetching: + +```vue + + + +``` + +> [!NOTE] +> One of the reasons to block the navigation while fetching is to align with the upcoming [Navigation API](https://github.com/WICG/navigation-api) which will show a spinning indicator (same as when entering a URL) on the browser UI while the navigation is blocked. + +This setup has many limitations: + +- Nested routes will force **sequential data fetching**: it's not possible to ensure an **optimal parallel fetching** +- Manual data refreshing is necessary **unless you add a `key` attribute** to the `` which will force a remount of the component on navigation. This is not ideal because it will remount the component on every navigation, even when the data is the same. It's necessary if you want to do a `` but less flexible than the proposed solution which also works with a `key` if needed. +- By putting the fetching logic within the `setup()` of the component we face other issues: + + - No abstraction of the fetching logic => **code duplication** when fetching the same data in multiple components + - No native way to dedupe requests among multiple components using them: it requires using a store and extra logic to skip redundant fetches (see bottom of [Nested Invalidation](#nested-invalidation) ) + - Requires mounting the upcoming page component (while the navigation is still blocked) which can be **expensive in terms of rendering and memory** as we still need to render the old page while we _**try** to mount the new page_. + +- No native way of caching data, even for very simple cases (e.g. no refetching when fast traveling back and forward through browser UI) +- Not possible to precisely read (or write) the loading state (see [vuejs/core#1347](https://github.com/vuejs/core/issues/1347)]) + +On top of this it's important to note that this RFC doesn't limit you: you can still use Suspense for data fetching or even use both, **this API is completely tree shakable** and doesn't add any runtime overhead if you don't use it. Keeping the progressive enhancement nature of Vue.js. + +## Other alternatives + +- Allowing blocking data loaders to return objects of properties: + + ```ts + export const useUserData = defineLoader(async (route) => { + const user = await getUserById(route.params.id) + // instead of return user + return { user } + }) + // instead of const { data: user } = useUserData() + const { user } = useUserData() + ``` + + This was the initial proposal but since this is not possible with lazy loaders it was more complex and less intuitive. Having one single version is overall easier to handle. + +- Adding a new ` + + + ``` + + Is exposing every variable a good idea? + +- Pass route properties instead of the whole `route` object: + + ```ts + import { getUserById } from '../api' + + export const useUserData = defineLoader(async ({ params, query, hash }) => { + const user = await getUserById(params.id) + return { user } + }) + ``` + + This has the problem of not being able to use the `route.name` to determine the correct typed params (with unplugin-vue-router): + + ```ts + import { getUserById } from '../api' + + export const useUserData = defineLoader(async (route) => { + if (route.name === 'user-details') { + const user = await getUserById(params.id) + // ^ Typed! + return { user } + } + }) + ``` + +## Naming + +Variables could be named differently and proposals are welcome: + +- `pending` (same as Nuxt) -> `isPending`, `isLoading` +- Rename `defineLoader()` to `defineDataFetching()` (or others) + +## Nested/Sequential Loaders drawbacks + +- Allowing `await getUserById()` could make people think they should also await inside ` + + +``` + +Note that lazy loaders can only control their own blocking mechanism. They can't control the blocking of other loaders. If multiple loaders are being used and one of them is blocking, the navigation will be blocked until all of the blocking loaders are resolved. + +A function could allow to conditionally block upon navigation: + +```ts +export const useUserData = defineLoader( + loader, + // ... + { + lazy: (route) => { + // ... + return true // or a number + } + } +) +``` + +# Adoption strategy + +Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to test it first and make it part of the router later on. + +# Unresolved questions + +- Should there by an `afterLoad()` hook, similar to `beforeLoad()`? +- What else is needed besides the `route` inside loaders? +- Add option for placeholder data? Maybe some loaders should do that. +- What other operations might be necessary for users? + +