From d697789115a32132ea5e4db62b0931b482e7a6bb Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 11 Feb 2022 18:24:10 +0100 Subject: [PATCH 01/25] wip --- active-rfcs/0000-router-onBeforeNavigate.md | 245 ++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 active-rfcs/0000-router-onBeforeNavigate.md diff --git a/active-rfcs/0000-router-onBeforeNavigate.md b/active-rfcs/0000-router-onBeforeNavigate.md new file mode 100644 index 00000000..cf95cf95 --- /dev/null +++ b/active-rfcs/0000-router-onBeforeNavigate.md @@ -0,0 +1,245 @@ +- Start Date: 2022-02-11 +- Target Major Version: Vue Router >=4 +- Reference Issues: +- Implementation PR: + +# Summary + +Better integrating Data Fetching logic with the router navigation: + +- Automatically include `await` calls within an ongoing navigation +- Navigation guards declared **within** `setup()` +- Loading and Error Boundaries thanks to Suspense +- Single Fetching on Hydration (avoid fetching on server and client when doing SSR) + +# Basic example + +```vue +<script setup lang="ts"> +import { onBeforeNavigate } from 'vue-router' +import { useUserStore } from '~/stores/user' // store with pinia + +const userStore = useUserStore() + +// Triggers on entering or updating the route: +// / -> /users/2 ✅ +// /users/2 -> /users/3 ✅ +// /users/2 -> / ❌ +await onBeforeNavigate(async (to, from) => { + // could also be return userStore.fetchUser(...) + await userStore.fetchUser(to.params.id) +}) +</script> + +<template> + <h2>{{ userStore.userInfo.name }}</h2> + <ul> + <li>Email: {{ userStore.userInfo.email }}</li> + ... + </ul> +</template> +``` + +## Fetching data once + +You can do initial fetching (when it only needs to happen once) **without using anything specific to Vue Router**: + +```vue +<script setup lang="ts"> +import { getUserList } from '~/apis/user' + +const userList = await getUserList() +// or if you need a reactive version +const userList = reactive(await getUserList()) +</script> +``` + +- The router navigation won't be considered settled until all `await` (or returned promises) are +- This creates a redundant fetch with SSR because `userList` is never serialized into the page for hydration. In other words: **don't do this if you need SSR**, use a store or `useDataFetching()`. + +## Fetching with each navigation + +Use this when the fetching depends on the route (params, query, ...) like a route `/users/:id` that allows navigating through users. + +- Any component that is rendered by RouterView or one of its children can call `onBeforeNavigate()` +- `onBeforeNavigate()` triggers on entering, updating, and leaving, so it would make sense to expose other variations like enter + update (TODO: find a proper name for `onBeforeRouteEnterOrUpdate()`, maybe `onBeforeNavigateIn()`) + +## SSR support + +To properly handle SSR we need to: + +- Serialize the fetched data to hydrate +- Avoid the double fetching issue by only fetching on the server + +To avoid the double fetching issue, we can detect the hydration during the initial navigation and skip navigation guards altogether as they were already executed on the server. + +### Using a store like Pinia + +Using a store allows to solve the serialization issue + +```vue +<script setup lang="ts"> +import { onBeforeNavigate } from 'vue-router' +import { storeToRefs } from 'pinia' +import { useUserStore } from '~/stores/user' // store with pinia + +const userStore = useUserStore() +const { userInfo } = storeToRefs(userStore) + +// The navigation guard will be skipped during the first rendering on client side because everything was done on the server +// This will avoid the double fetching issue as well and ensure the navigation is consistent +await onBeforeNavigate(async (to, from) => { + // we could return a promise too + await userStore.fetchUser(to.params.id) + + // here we could also return false, "/login", throw new Error() + // like a regular navigation guard +}) +</script> + +<template> + <h2>{{ userInfo.name }}</h2> + <ul> + <li>Email: {{ userInfo.email }}</li> + ... + </ul> +</template> +``` + +### Using a new custom `useDataFetching()` + +Maybe we could expose a utility to handle SSR **without a store** but I think [serialize-revive](https://github.com/kiaking/vue-serialize-revive) should come first. Maybe internally they could use some kind of `onSerialization(key, fn)`/`onHydration(key, fn)`. + +```vue +<script setup lang="ts"> +import { onBeforeNavigate, useDataFetching } from 'vue-router' +import { getUserInfo } from '~/apis/user' + +// only necessary with SSR without Pinia (or any other store) +const userInfo = useDataFetching(getUserInfo) +// this await allows for Suspense to wait for the _entering_ navigation +await onBeforeNavigate(async (to, from) => { + // await (we could return a promise too) + await userInfo.fetch(to.params.id) +}) +</script> + +<template> + <h2>{{ userInfo.name }}</h2> + <ul> + <li>Email: {{ userInfo.email }}</li> + ... + </ul> +</template> +``` + +`useDataFetching()` is about being able to hydrate the information but `onBeforeNavigate()` only needs to return a promise: + +# Motivation + +Today's data fetching with Vue Router is flawed + +Why are we doing this? What use cases does it support? What is the expected +outcome? + +Please focus on explaining the motivation so that if this RFC is not accepted, +the motivation could be used to develop alternative solutions. In other words, +enumerate the constraints you are trying to solve without coupling them too +closely to the solution you have in mind. + +# Detailed design + +## Failed Navigations + +A failed navigation is a navigation that is canceled by the code (e.g. unauthorized access to an admin page) and results in the route **not changing**. It triggers `router.afterEach()`. Note this doesn't include redirecting (e.g. `return "/login"` but not `redirect: '/login'` (which is a "rewrite")) as they trigger a whole new navigation that gets "appended" to the ongoing navigation. + +## Errored Navigations + +An errored navigation is different from a failed navigation because it comes from an unexpected uncaught thrown error. It triggers `router.onError()`. + +# Drawbacks + +## Using Suspense + +Currently Suspense allows displaying a `fallback` content after a certain timeout. This is useful when displaying a loading screen but there are other ways of telling the user we are waiting for asynchronous operations in the back like a progress bar or a global loader. +The issue with the `fallback` slot is it is **only controlled by `await` inside of mounting component**. Meaning there is no programmatic API to _reenter_ the loading state and display the `fallback` slot in this scenario: + +Given a Page Component for `/users/:id`: + +```vue +<script setup lang="ts"> +import { onBeforeNavigate } from 'vue-router' +import { useUserStore } from '~/stores/user' // store with pinia + +const userStore = useUserStore() + +await onBeforeNavigate(async (to, from) => { + await userStore.fetchUser(to.params.id) +}) +</script> + +<template> + User: {{ userStore.user.name }} + <ul> + <li v-for="user in userStore.user.friends"> + <RouterLink :to="`/users/${user.id}`">{{ user.name }}</RouterLink> + </li> + </ul> +</template> +``` + +And an App.vue as follows + +```vue +<template> + <!-- SuspendedRouterView internally uses Suspense around the rendered page and inherits all of its props and slots --> + <SuspendedRouterView :timeout="800"> + <template #fallback> Loading... </template> + </SuspendedRouterView> +</template> +``` + +- Going from any other page to `/users/1` displays the "Loading..." message if the request for the user information takes more than 800ms +- Clicking on any of the users and effectively navigating to `/users/2` + +# Alternatives + +What other designs have been considered? What is the impact of not doing this? + +- Not using Suspense: Implementing vue router's own fallback/error mechanism: not sure if doable because we need to try mounting the components to "collect" their navigation guards. + +## To Suspense `fallback` not displaying on the same route navigations + +We could retrieve the status of the closest `<Suspense>` boundary: + +```js +const { state } = useSuspense() +state // TBD: Ref<'fallback' | 'settled' | 'error'> +``` + +There could maybe also be a way to programmatically change the state of the closest Suspense boundary: + +```js +const { appendJob } = useSuspense() +// add a promise, enters the loading state if Suspense is not loading +appendJob((async () => { + // some async operation +})()) +``` + +# Adoption strategy + +If we implement this proposal, how will existing Vue developers adopt it? Is +this a breaking change? Can we write a codemod? Can we provide a runtime adapter library for the original API it replaces? How will this affect other projects in the Vue ecosystem? + +# Unresolved questions + +- What should happen when an error is thrown (See https://github.com/vuejs/core/issues/1347) + - Currently the `fallback` slot stays visible + - Should the router rollback the previous route location? +- `useDataFetching()`: + + - Do we need anything else apart from `data`, and `fetch()`? + - loading/error state: even though it can be handled by Suspense `fallback` slot, we could still make them appear here for users not using a `fallback` slot + +- Maybe we should completely skip the navigation guards when hydrating From 552c1653ce75d435a38fb425e8402ba0f7b1f053 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Thu, 14 Jul 2022 10:10:18 +0200 Subject: [PATCH 02/25] wp with use loader notes --- active-rfcs/0000-router-onBeforeNavigate.md | 124 +++++++++++++----- active-rfcs/0000-router-use-loader.md | 137 ++++++++++++++++++++ 2 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 active-rfcs/0000-router-use-loader.md diff --git a/active-rfcs/0000-router-onBeforeNavigate.md b/active-rfcs/0000-router-onBeforeNavigate.md index cf95cf95..4071b04d 100644 --- a/active-rfcs/0000-router-onBeforeNavigate.md +++ b/active-rfcs/0000-router-onBeforeNavigate.md @@ -7,13 +7,38 @@ Better integrating Data Fetching logic with the router navigation: -- Automatically include `await` calls within an ongoing navigation +- Automatically include `await` calls within an ongoing navigation for components that are mounted as a result of the navigation - Navigation guards declared **within** `setup()` - Loading and Error Boundaries thanks to Suspense - Single Fetching on Hydration (avoid fetching on server and client when doing SSR) # Basic example +- Fetching data once + +Data fetching when entering the router. This is simpler than the `onBeforeNavigate` example and is what most users would expect to work. It also integrates with the navigation: + +```vue +<script setup lang="ts"> +import { useUserStore } from '~/stores/user' // store with pinia + +const userStore = useUserStore() +await userStore.fetchUser(to.params.id) +</script> + +<template> + <h2>{{ userStore.userInfo.name }}</h2> + <ul> + <li>Email: {{ userStore.userInfo.email }}</li> + ... + </ul> +</template> +``` + +- Fetching data with each navigation + +Data fetching integrates with the router navigation within any component. + ```vue <script setup lang="ts"> import { onBeforeNavigate } from 'vue-router' @@ -25,7 +50,7 @@ const userStore = useUserStore() // / -> /users/2 ✅ // /users/2 -> /users/3 ✅ // /users/2 -> / ❌ -await onBeforeNavigate(async (to, from) => { +onBeforeNavigate(async (to, from) => { // could also be return userStore.fetchUser(...) await userStore.fetchUser(to.params.id) }) @@ -40,6 +65,12 @@ await onBeforeNavigate(async (to, from) => { </template> ``` +# Motivation + +Today's data fetching with Vue Router is simple when the data fetching is simple enough but it gets complicated to implement in advanced use cases that involve SSR. It also doesn't work with `async setup()` + Suspense. + +# Detailed design + ## Fetching data once You can do initial fetching (when it only needs to happen once) **without using anything specific to Vue Router**: @@ -54,28 +85,50 @@ const userList = reactive(await getUserList()) </script> ``` -- The router navigation won't be considered settled until all `await` (or returned promises) are -- This creates a redundant fetch with SSR because `userList` is never serialized into the page for hydration. In other words: **don't do this if you need SSR**, use a store or `useDataFetching()`. +- The router navigation won't be considered settled until all `await` (or returned promises inside `setup()`) are resolved +- This creates a redundant fetch with SSR because `userList` is never serialized into the page for hydration. In other words: **don't do this if you are doing SSR**. +- Simplest data fetching pattern, some people are probably already using this pattern. +- Rejected promises are caught by `router.onError()` and cancel the navigation. Note that differently from `onErrorCaptured()`, it's not possible to return `false` to stop propagating the error. ## Fetching with each navigation -Use this when the fetching depends on the route (params, query, ...) like a route `/users/:id` that allows navigating through users. +Use this when the fetching depends on the route (params, query, ...) like a route `/users/:id` that allows navigating through users. All the rules above apply: + +```vue +<script setup lang="ts"> +import { onBeforeNavigate } from 'vue-router' +import { getUser } from '~/apis/user' + +const user = ref<User>() +onBeforeNavigate(async (to) => { + user.value = await getUser(to.params.id) +}) +</script> +``` - Any component that is rendered by RouterView or one of its children can call `onBeforeNavigate()` -- `onBeforeNavigate()` triggers on entering, updating, and leaving, so it would make sense to expose other variations like enter + update (TODO: find a proper name for `onBeforeRouteEnterOrUpdate()`, maybe `onBeforeNavigateIn()`) +- `onBeforeNavigate()` triggers on entering and, updating. It **doesn't trigger when leaving**. This is because it's mostly used to do data fetching and you don't want to fetch when leaving. You can still use `onBeforeRouteLeave()`. +- Can be called multiple times +- It returns a promise that resolves when the fetching is done. This allows to await it to use any value that is updated within the navigation guard: + + ```ts + const user = ref<User>() + await onBeforeNavigate(async (to) => { + user.value = await getUser(to.params.id) + }) + user.value // populated because we awaited + ``` + +- Awaiting or not `onBeforeNavigate()` doesn't change the fact that the navigation is settled only when all `await` are resolved. ## SSR support To properly handle SSR we need to: -- Serialize the fetched data to hydrate +- Serialize the fetched data to hydrate: **which is not handled by the router** - Avoid the double fetching issue by only fetching on the server -To avoid the double fetching issue, we can detect the hydration during the initial navigation and skip navigation guards altogether as they were already executed on the server. - -### Using a store like Pinia - -Using a store allows to solve the serialization issue +Serializing the state is out of scope for the router. Nuxt defines a `useState()` composable. A pinia store can also be used to store the data: ```vue <script setup lang="ts"> @@ -106,6 +159,17 @@ await onBeforeNavigate(async (to, from) => { </template> ``` +To avoid the double fetching issue, we can detect the hydration during the initial navigation and skip navigation guards altogether as they were already executed on the server. **This is however a breaking change**, so it would require a new option to `createRouter()` that disables the feature by default: + +```ts +createRouter({ + // ... + skipInitialNavigationGuards: true +}) +``` + +Another solution is to only skip `onBeforeNavigate()` guards during the initial navigation on the client if it was hydrated. This is not a breaking change since the API is new but it does make things inconsistent. + ### Using a new custom `useDataFetching()` Maybe we could expose a utility to handle SSR **without a store** but I think [serialize-revive](https://github.com/kiaking/vue-serialize-revive) should come first. Maybe internally they could use some kind of `onSerialization(key, fn)`/`onHydration(key, fn)`. @@ -135,29 +199,19 @@ await onBeforeNavigate(async (to, from) => { `useDataFetching()` is about being able to hydrate the information but `onBeforeNavigate()` only needs to return a promise: -# Motivation - -Today's data fetching with Vue Router is flawed - -Why are we doing this? What use cases does it support? What is the expected -outcome? +## Canceled Navigations -Please focus on explaining the motivation so that if this RFC is not accepted, -the motivation could be used to develop alternative solutions. In other words, -enumerate the constraints you are trying to solve without coupling them too -closely to the solution you have in mind. - -# Detailed design +A canceled navigation is a navigation that is canceled by the code (e.g. unauthorized access to an admin page) and results in the route **not changing**. It triggers `router.afterEach()`. Note this doesn't include redirecting (e.g. `return "/login"` but not `redirect: '/login'` (which is a _"rewrite"_ of the ongoing navigation)) as they trigger a whole new navigation that gets _"appended"_ to the ongoing navigation. ## Failed Navigations -A failed navigation is a navigation that is canceled by the code (e.g. unauthorized access to an admin page) and results in the route **not changing**. It triggers `router.afterEach()`. Note this doesn't include redirecting (e.g. `return "/login"` but not `redirect: '/login'` (which is a "rewrite")) as they trigger a whole new navigation that gets "appended" to the ongoing navigation. +An failed navigation is different from a canceled navigation because it comes from an unexpected uncaught thrown error. It also triggers `router.onError()`. -## Errored Navigations +# Drawbacks -An errored navigation is different from a failed navigation because it comes from an unexpected uncaught thrown error. It triggers `router.onError()`. +## Larger CPU/Memory usage -# Drawbacks +In order to execute any `await` statement, we have to mount the pending routes within a Suspense boundary while still displaying the current view which means two entire views are rendered during the navigation. This can be slow for big applications but no real testing has been done to prove this could have an impact on UX. ## Using Suspense @@ -188,7 +242,7 @@ await onBeforeNavigate(async (to, from) => { </template> ``` -And an App.vue as follows +And an `App.vue` as follows ```vue <template> @@ -222,11 +276,17 @@ There could maybe also be a way to programmatically change the state of the clos ```js const { appendJob } = useSuspense() // add a promise, enters the loading state if Suspense is not loading -appendJob((async () => { - // some async operation -})()) +appendJob( + (async () => { + // some async operation + })() +) ``` +## Suspense Limitations + +- Any provided/computed value is updated even in the non active branches of Suspense. Would there be a way to ensure `computed` do not update (BTW keep alive also do this) or to freeze provided reactive values? + # Adoption strategy If we implement this proposal, how will existing Vue developers adopt it? Is diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md new file mode 100644 index 00000000..bb79fd63 --- /dev/null +++ b/active-rfcs/0000-router-use-loader.md @@ -0,0 +1,137 @@ +- Start Date: 2022-07-14 +- Target Major Version: Vue 3, Vue Router 4 +- Reference Issues: +- Implementation PR: + +# Summary + +Provide a set of functions `defineLoader()` and `useLoader()` to standardize and improve data fetching: + +- Automatically integrate fetching to the navigation cycle +- Automatically rerun when used params changes (avoid unnecessary fetches) +- Provide control over loading/error states + +# Basic example + +If the proposal involves a new or changed API, include a basic code example. +Omit this section if it's not applicable. + +```vue +<script lang="ts"> +import { getUserById } from '../api' +import { defineLoader } from '@vue-router' + +export const loader = defineLoader(async (route) => { + const user = await getUserById(route.params.id) + // ... + return { user } +}) + +// Optional: define other component options +export default defineComponent({ + name: 'custom-name', + inheritAttrs: false +}) +</script> + +<script lang="ts" setup> +import { useLoader } from '@vue-router' + +const { user, isLoading, error } = useLoader() +// user is always present, isLoading changes when going from '/users/2' to '/users/3' +</script> +``` + +# Motivation + +There are currently too many ways of handling data fetching with vue-router and all of them have problems: + +- With navigation guards: + - using `meta`: complex to setup even for simple cases, too low level for such a simple case + - using `onBeforeRouteUpdate()`: missing the enter part + - `beforeRouteEnter()`: non typed and non-ergonomic API with `next()`, requires a data store (pinia, vuex, apollo, etc) + - using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) + +# Detailed design + +This is the bulk of the RFC. Explain the design in enough detail for somebody +familiar with Vue to understand, and for somebody familiar with the +implementation to implement. This should get into specifics and corner-cases, +and include examples of how the feature is used. Any new terminology should be +defined here. + +## 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()` and `useLoader()` the possible values of the current types: + +```vue +<script lang="ts"> +import { getUserById } from '../api' +import { defineLoader } from '@vue-router' + +export const loader = defineLoader('/users/[id]', async (route) => { + // ^ autocompleted + const user = await getUserById(route.params.id) + // ^ typesafe + // ... + return { user } +}) +</script> + +<script lang="ts" setup> +import { useLoader } from '@vue-router' + +const { user, isLoading, error } = useLoader('/users/[id]') +// ^ autocompleted +// same as +const { user, isLoading, error } = useLoader<'/users/[id]'>() +</script> +``` + +The arguments can be removed during the compilation step in production mode since they are only used for types and are actually ignored at runtime. + +# Drawbacks + +Why should we _not_ do this? Please consider: + +- implementation cost, both in term of code size and complexity +- whether the proposed feature can be implemented in user space +- the impact on teaching people Vue +- integration of this feature with other existing and planned features +- cost of migrating existing Vue applications (is it a breaking change?) + +There are tradeoffs to choosing any path. Attempt to identify them here. + +# Alternatives + +- Adding a new `<script loader>` similar to `<script setup>`: + +```vue +<script lang="ts" loader> +import { getUserById } from '@/api/users' +import { useRoute } from '@vue-router' // could be automatically imported + +const route = useRoute() +// any variable created here is available in useLoader() +const user = await getUserById(route.params.id) +</script> + +<script lang="ts" setup> +import { useLoader } from '@vue-router' + +const { user, isLoading, error } = useLoader() +</script> +``` + +Is exposing every variable a good idea? + +- Using Suspense to natively handle `await` within `setup()`. [See other RFC](#TODO). + +# 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 + +Optional, but suggested for first drafts. What parts of the design are still +TBD? From 283a75ceffd39a4bb8fc1dd94541ce626546eea8 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Thu, 21 Jul 2022 18:26:35 +0200 Subject: [PATCH 03/25] more --- active-rfcs/0000-router-use-loader.md | 301 ++++++++++++++++++++++---- 1 file changed, 253 insertions(+), 48 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index bb79fd63..44990435 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -5,25 +5,27 @@ # Summary -Provide a set of functions `defineLoader()` and `useLoader()` to standardize and improve data fetching: +Standarize and improve data fetching by adding helpers to be called within page components: -- Automatically integrate fetching to the navigation cycle +- Automatically integrate fetching to the navigation cycle (or not by making it non-blocking) - Automatically rerun when used params changes (avoid unnecessary fetches) - Provide control over loading/error states +- Allow parallel or sequential data fetching (loaders that use each other) -# Basic example +This proposal concerns the Vue Router 4 with [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) -If the proposal involves a new or changed API, include a basic code example. -Omit this section if it's not applicable. +# Basic example ```vue <script lang="ts"> import { getUserById } from '../api' import { defineLoader } from '@vue-router' -export const loader = defineLoader(async (route) => { +// name the loader however you want **and export it** +export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) // ... + // return anything you want to expose return { user } }) @@ -35,103 +37,306 @@ export default defineComponent({ </script> <script lang="ts" setup> -import { useLoader } from '@vue-router' - -const { user, isLoading, error } = useLoader() -// user is always present, isLoading changes when going from '/users/2' to '/users/3' +// find `user` and some other properties +const { user, pending, error, refresh } = useUserData() +// user is always present, pending changes when going from '/users/2' to '/users/3' </script> ``` +- `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. +- **Only page components** can export loaders but loaders can be defined anywhere. + # Motivation There are currently too many ways of handling data fetching with vue-router and all of them have problems: - With navigation guards: - - using `meta`: complex to setup even for simple cases, too low level for such a simple case - - using `onBeforeRouteUpdate()`: missing the enter part + - using `meta`: complex to setup even for simple cases, too low level for such a common case + - using `onBeforeRouteUpdate()`: missing data when entering tha page - `beforeRouteEnter()`: non typed and non-ergonomic API with `next()`, requires a data store (pinia, vuex, apollo, etc) - using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) +The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. + # Detailed design -This is the bulk of the RFC. Explain the design in enough detail for somebody -familiar with Vue to understand, and for somebody familiar with the -implementation to implement. This should get into specifics and corner-cases, -and include examples of how the feature is used. Any new terminology should be -defined here. +This design is possible though [unplugin-vue-router](https://github.com/posva/unplugin-vue-router): + +- Check named exports in page components to set a meta property in the generated routes +- Adds a navigation guard that resolve loaders +- Implement a `defineLoader()` composable + +`defineLoader()` takes a function that returns a promise and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when accessing + +## Parallel Fetching + +By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires params or nothing at all. + +## Sequential fetching + +Sometimes, requests depend on other fetched data (e.g. fetching additional user information). For these scenarios, we can simply import the other loaders and use them **within a different loader**: + +Call the loader inside the one that needs it, it will only be fetched once + +```ts +export const useUserFriends = defineLoader(async (route) => { + const { user, isLoaded() } = useUserData() + await isLoaded() + const friends = await getFriends(user.value.id) + return { user, friends } +}) +``` + +Note that two loaders cannot use each other as that would create a _dead lock_. + +### Alternatives + +- Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. + +## Combining loaders + +```js +export const useLoader = combineLoaders(useUserLoader, usePostLoader) +``` + +## Loader reusing + +Each loader has its own cache attached to the application + +```ts +// the map key is the function passed to defineLoader +app.provide('loaderMap', new WeakMap<Function>()) + +// then the dataLoader + +export const useLoader = (loader: Function) => { + const loaderMap = inject('loaderMap') + if (!loaderMap.has(loader)) { + // create it and set it + } + // reuse the loader instance +} +``` + +We could transform the `defineLoader()` calls to include an extra argument (and remove the first one if it's a route path) to be used as the `key` for the loader cache. +NOTE: or we could just use the function name + id in dev, and just id number in prod + +## Usage outside of page components + +Loaders can be **only exported from pages**. That's where the plugin picks it up, but the page doesn't even need to use it. It can be used in any component by importing the function, even outside of the scope of the page components. ## 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()` and `useLoader()` the possible values of the current types: +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 <script lang="ts"> import { getUserById } from '../api' import { defineLoader } from '@vue-router' -export const loader = defineLoader('/users/[id]', async (route) => { +export const useUserData = defineLoader('/users/[id]', async (route) => { // ^ autocompleted const user = await getUserById(route.params.id) - // ^ typesafe + // ^ typed! // ... return { user } }) </script> <script lang="ts" setup> -import { useLoader } from '@vue-router' - -const { user, isLoading, error } = useLoader('/users/[id]') -// ^ autocompleted -// same as -const { user, isLoading, error } = useLoader<'/users/[id]'>() +const { user, pending, error } = useUserData() </script> ``` The arguments can be removed during the compilation step in production mode since they are only used for types and are actually ignored at runtime. -# Drawbacks +## Testing pages -Why should we _not_ do this? Please consider: +Testing the pages is now easy -- implementation cost, both in term of code size and complexity -- whether the proposed feature can be implemented in user space -- the impact on teaching people Vue -- integration of this feature with other existing and planned features -- cost of migrating existing Vue applications (is it a breaking change?) +## Named views -There are tradeoffs to choosing any path. Attempt to identify them here. +When using vue router named views, each named view can have their own loader but note any navigation to the route will trigger **all loaders from all page components**. -# Alternatives +Note: a named view can be declared by appending `@name` at the end of the file name: -- Adding a new `<script loader>` similar to `<script setup>`: +``` +src/pages/ +└── users/ + ├── index.vue + └── index@aux.vue +``` + +This creates a `components: { default: ..., aux: ... }` entry in the route config. + +## Reusing / Sharing loaders + +Loaders can be defined anywhere and imported and used (only in page components) when necessary. This allows to define loaders anywhere or even reuse loaders from a different page. +Any page component with named exports will be marked with a symbol to pick up any possible loader in a navigation guard. + +It's possible to combine multiple loaders into one loader with `combineLoaders()` to not combine the results of multiple loaders into a single object but also merge `pending`, `error`, etc ```vue -<script lang="ts" loader> -import { getUserById } from '@/api/users' -import { useRoute } from '@vue-router' // could be automatically imported +<script> +import { useUserData } from '@/pages/users/[id].vue' +import { usePostData } from '@/pages/posts/[id].vue' -const route = useRoute() -// any variable created here is available in useLoader() -const user = await getUserById(route.params.id) +export const useCombinedLoader = combineLoaders(useUserData, usePostData) </script> -<script lang="ts" setup> -import { useLoader } from '@vue-router' +<script setup> +const { + user, + post, + // both pending + pending, + // any error that happens + error, + // refresh both + refresh +} = useCombinedLoader() +</script> +``` + +## Non blocking data fetching -const { user, isLoading, error } = useLoader() +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 +<script lang="ts"> +import { getUserById } from '../api' + +export const useUserData = defineLoader( + async (route) => { + const user = await getUserById(route.params.id) + return { user } + }, + { lazy: true } +) </script> + +<script setup> +// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved +const { data, pending, error } = useUserData() +// ^ Ref<{ user: User } | undefined> +</script> +``` + +Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: + +```vue +<script lang="ts"> +import { getUserById } from '../api' + +export const useUserData = defineLoader( + async (route) => { + const user = await getUserById(route.params.id) + return { user } + }, + // block the navigation for 1 second and then let the navigation go through + { lazy: 1000 } +) +</script> + +<script setup> +// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved +const { data, pending, error } = useUserData() +// ^ Ref<{ user: User } | undefined> +</script> +``` + +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 them are resolved. + +## Refreshing the data + +### With navigation + +### Manually + +Manually call + +## Error handling + +If a request fails, the user can catch the error in the loader and return it differently + +## SSR + +### Avoiding double fetch on the client + +## Performance + +When fetching large data sets, 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 } +}) ``` -Is exposing every variable a good idea? +[More in Vue docs](https://vuejs.org/api/reactivity-advanced.html#markraw) + +An alternative would be to use `shallowRef()` instead of `ref()` 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. + +## HMR + +When changing the `<script>` the old cache is transferred and refreshed. + +## Extending `defineLoader()` + +It's possible to extend the `defineLoader()` function to add new features such as a more complex cache system. TODO: + +## Limitations + +- Injections (`inject`/`provide`) cannot be used within a loader +- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released. **This is why, using composables should be avoided**. + +# Drawbacks + +This solution is not a silver bullet but I don't think one exists because of the different data fetching strategies and how they can define the architecture of the application. + +- Less intuitive than just awaiting something inside `setup()` +- Can only be used in page components +- Requires an extra `<script>` tag but only for views + +# Alternatives + +- Adding a new `<script loader>` similar to `<script setup>`: + + ```vue + <script lang="ts" loader="useUserData"> + import { getUserById } from '@/api/users' + import { useRoute } from '@vue-router' // could be automatically imported + + const route = useRoute() + // any variable created here is available in useLoader() + const user = await getUserById(route.params.id) + </> + + <script lang="ts" setup> + import { useLoader } from '@vue-router' + + const { user, pending, error } = useUserData() + </> + ``` + + Is exposing every variable a good idea? - Using Suspense to natively handle `await` within `setup()`. [See other RFC](#TODO). +## Naming + +Variables could be named differently and proposals are welcome: + +- `pending` (same as Nuxt) -> `isPending`, `isLoading` +- `export const loader` (not a verb because it's not meant to be called like a function) -> `export const load` + # 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 -Optional, but suggested for first drafts. What parts of the design are still -TBD? +- Should there be a way to handle server only loaders? From c352ded790d612fe71acbff29dd0f14c52ec62a0 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 22 Jul 2022 11:20:37 +0200 Subject: [PATCH 04/25] add questions --- active-rfcs/0000-router-use-loader.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 44990435..ee7552b5 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -91,7 +91,7 @@ export const useUserFriends = defineLoader(async (route) => { Note that two loaders cannot use each other as that would create a _dead lock_. -### Alternatives +Alternatives: - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. @@ -249,6 +249,21 @@ const { data, pending, error } = useUserData() 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 them are resolved. +Or a function to decide upon navigation / refresh: + +```ts +export const useUserData = defineLoader( + loader, + // ... + { + lazy: (route) => { + // ... + return true // or a number + } + } +) +``` + ## Refreshing the data ### With navigation @@ -340,3 +355,5 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi # Unresolved questions - Should there be a way to handle server only loaders? +- Is `useNuxtApp()` usable within loaders? +- Is there anything needed besides the `route` inside loaders? From ad69da2aee9242ef88f036713db68f3ef274bb1b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 22 Jul 2022 15:29:48 +0200 Subject: [PATCH 05/25] more notes --- active-rfcs/0000-router-use-loader.md | 51 ++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index ee7552b5..c73a3d4f 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -3,6 +3,13 @@ - Reference Issues: - Implementation PR: +# Todo List + +List of things that haven't been added to the document yet: + +- Show how to use the data loader without `@vue-router` +- Explain what `@vue-router` brings + # Summary Standarize and improve data fetching by adding helpers to be called within page components: @@ -60,6 +67,12 @@ There are currently too many ways of handling data fetching with vue-router and The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. +There are features that are out of scope for this proposal but should be implementable in user land thanks to an _extendable API_: + +- Implement a full-fledged cahed API like vue-query +- Implement pagination +- Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`) + # Detailed design This design is possible though [unplugin-vue-router](https://github.com/posva/unplugin-vue-router): @@ -95,6 +108,22 @@ Alternatives: - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. +- Pass an array of loaders to the loader that needs them and use let it retrieve them through an argument: + + ```ts + import { useUserData } from '~/pages/users/[id].vue' + + export const useUserFriends = defineLoader( + async (route, [userData]) => { + const friends = await getFriends(user.value.id) + return { user, friends } + }, + { + waitFor: [useUserData] + } + ) + ``` + ## Combining loaders ```js @@ -200,6 +229,10 @@ const { </script> ``` +### Good practice for loader organization + +Data loaders can be imported in any component but this could also affect the way your code is code split. It's worth explaining the benefits of moving a data loader to a different file and import it in other components. + ## Non blocking data fetching 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**. @@ -224,6 +257,8 @@ const { data, pending, error } = useUserData() </script> ``` +A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replaces the one being shown to make it even more confusing for the end user. + Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: ```vue @@ -303,6 +338,18 @@ When changing the `<script>` the old cache is transferred and refreshed. It's possible to extend the `defineLoader()` function to add new features such as a more complex cache system. TODO: +ideas: + +- Export an interface that must be implemented by a composable so external libraries can implement custom strategies (e.g. vue-query) +- allow global and local config (+ types) + +## Global API + +It's possible to access a global state of when data loaders are fetching (navigation + calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). + +- `isFetchingData` +- `isNavigationFetching` + ## Limitations - Injections (`inject`/`provide`) cannot be used within a loader @@ -318,6 +365,7 @@ This solution is not a silver bullet but I don't think one exists because of the # Alternatives +- Allowing a `before` and `after` hook to allow changing data after each loader call. e.g. By default the data is preserved while a new one is being fetched - Adding a new `<script loader>` similar to `<script setup>`: ```vue @@ -346,7 +394,7 @@ This solution is not a silver bullet but I don't think one exists because of the Variables could be named differently and proposals are welcome: - `pending` (same as Nuxt) -> `isPending`, `isLoading` -- `export const loader` (not a verb because it's not meant to be called like a function) -> `export const load` +- Rename `defineLoader()` to `defineDataFetching()` (or others) # Adoption strategy @@ -357,3 +405,4 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi - Should there be a way to handle server only loaders? - Is `useNuxtApp()` usable within loaders? - Is there anything needed besides the `route` inside loaders? +- Add option for placeholder data? From 3d4e6cb18a9553ffab05246dae8c789fd4edab09 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Mon, 25 Jul 2022 18:21:34 +0200 Subject: [PATCH 06/25] reorganize --- active-rfcs/0000-router-use-loader.md | 295 ++++++++++++++++++-------- 1 file changed, 201 insertions(+), 94 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index c73a3d4f..b1282a38 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -16,17 +16,20 @@ Standarize and improve data fetching by adding helpers to be called within page - Automatically integrate fetching to the navigation cycle (or not by making it non-blocking) - Automatically rerun when used params changes (avoid unnecessary fetches) +- Basic client-side caching with time-based expiration to only fetch once per navigation while using it anywhere - Provide control over loading/error states - Allow parallel or sequential data fetching (loaders that use each other) -This proposal concerns the Vue Router 4 with [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) +This proposal concerns the Vue Router 4 but some examples concern [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) usage for improved DX. Especially the typed routes usage. # Basic example +We can define any amount of loaders by **exporting them** in **page components**. They return a **composable that can be used anywhere in the app**. + ```vue <script lang="ts"> import { getUserById } from '../api' -import { defineLoader } from '@vue-router' +import { defineLoader } from 'vue-router' // name the loader however you want **and export it** export const useUserData = defineLoader(async (route) => { @@ -69,19 +72,98 @@ The goal of this proposal is to provide a simple yet configurable way of definin There are features that are out of scope for this proposal but should be implementable in user land thanks to an _extendable API_: -- Implement a full-fledged cahed API like vue-query +- Implement a full-fledged cached API like vue-query - Implement pagination -- Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`) +- Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`, no refetch on focus, etc) # Detailed design -This design is possible though [unplugin-vue-router](https://github.com/posva/unplugin-vue-router): +Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the loaders where they should **but it's not necessary**. By default, it: -- Check named exports in page components to set a meta property in the generated routes +- Checks named exports in page components to set a meta property in the generated routes - Adds a navigation guard that resolve loaders -- Implement a `defineLoader()` composable +- Implements a `defineLoader()` composable (should be moved to vue-router later) + +`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when accessing + +Limiting the loader access to only the target route, ensures that the data can be fetched when the user refresh the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params. Within loaders there is no current instance and no access to `inject`/`provide` APIs. + +Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many page components export the loader or how many regular components use it. + +## Setup + +To setup the loaders, we first need to setup the navigation guards: + +```ts +import { setupDataFetchingGuard, createRouter } from 'vue-router' + +const router = createRouter({ + // ... +}) + +setupDataFetchingGuard(router) +``` -`defineLoader()` takes a function that returns a promise and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when accessing +Then, for each page exporting a loader, we need to add a meta property to the route: + +```ts +import { LoaderSymbol } from 'vue-router' + +const routes = [ + { + path: '/users/:id', + component: () => import('@/pages/UserDetails.vue'), + meta: { + // 👇 v array of all the necessary loaders + [LoaderSymbol]: [() => import('@/pages/UserDetails.vue')] + }, + }, + // named views + { + path: '/users/:id', + components: { + default () => import('@/pages/UserDetails.vue'), + aux () => import('@/pages/UserDetailsAux.vue'), + }, + meta: { + [LoaderSymbol]: [() => import('@/pages/UserDetails.vue'), () => import('@/pages/UserDetailsAux.vue')], + }, + }, + // Nested routes follow the same pattern, declare an array of lazy imports relevant to each routing level + { + path: '/users/:id', + component: () => import('@/pages/UserDetails.vue'), + meta: { + [LoaderSymbol]: [() => import('@/pages/UserDetails.vue')], + }, + children: [ + { + path: 'edit', + component: () => import('@/pages/UserEdit.vue'), + meta: { + [LoaderSymbol]: [() => import('@/pages/UserEdit.vue')], + }, + } + ] + }, +] +``` + +This is **pretty verbose** and that's why it is recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to make this **completely automatic**: it will automatically generate the routes with the symbols and loaders. It will also setup the navigation guard when creating the router instance. +When using the plugin, any page component with **named exports will be marked** with a symbol to pick up any possible loader in a navigation guard. + +When using vue router named views, each named view can have their own loader but note any navigation to the route will trigger **all loaders from all page components**. + +Note: with unplugin-vue-router, a named view can be declared by appending `@name` at the end of the file name: + +``` +src/pages/ +└── users/ + ├── index.vue + └── index@aux.vue +``` + +This creates a `components: { default: ..., aux: ... }` entry in the route config. ## Parallel Fetching @@ -95,7 +177,7 @@ Call the loader inside the one that needs it, it will only be fetched once ```ts export const useUserFriends = defineLoader(async (route) => { - const { user, isLoaded() } = useUserData() + const { user, isLoaded } = useUserData() await isLoaded() const friends = await getFriends(user.value.id) return { user, friends } @@ -124,86 +206,45 @@ Alternatives: ) ``` -## Combining loaders - -```js -export const useLoader = combineLoaders(useUserLoader, usePostLoader) -``` - -## Loader reusing +## Cache and loader reuse -Each loader has its own cache attached to the application +Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per -```ts -// the map key is the function passed to defineLoader -app.provide('loaderMap', new WeakMap<Function>()) - -// then the dataLoader - -export const useLoader = (loader: Function) => { - const loaderMap = inject('loaderMap') - if (!loaderMap.has(loader)) { - // create it and set it - } - // reuse the loader instance -} -``` +TODO: expiration time of cache -We could transform the `defineLoader()` calls to include an extra argument (and remove the first one if it's a route path) to be used as the `key` for the loader cache. -NOTE: or we could just use the function name + id in dev, and just id number in prod - -## Usage outside of page components - -Loaders can be **only exported from pages**. That's where the plugin picks it up, but the page doesn't even need to use it. It can be used in any component by importing the function, even outside of the scope of the page components. - -## TypeScript +## Refreshing the data -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: +The data is refreshed automatically based on what params and query params are used within -```vue -<script lang="ts"> -import { getUserById } from '../api' -import { defineLoader } from '@vue-router' +### With navigation -export const useUserData = defineLoader('/users/[id]', async (route) => { - // ^ autocompleted - const user = await getUserById(route.params.id) - // ^ typed! - // ... - return { user } -}) -</script> +TODO: maybe an option `refresh`: -<script lang="ts" setup> -const { user, pending, error } = useUserData() -</script> +```ts +export const useUserData = defineLoader( + async (route) => { + await isLoaded() + const friends = await getFriends(user.value.id) + return { user, friends } + }, + { + // force refresh the data on navigation if /users/24?force=true + refresh: (route) => !!route.query.force + } +) ``` -The arguments can be removed during the compilation step in production mode since they are only used for types and are actually ignored at runtime. - -## Testing pages - -Testing the pages is now easy - -## Named views - -When using vue router named views, each named view can have their own loader but note any navigation to the route will trigger **all loaders from all page components**. +### Manually -Note: a named view can be declared by appending `@name` at the end of the file name: +Manually call the `refresh()` function to force the loader to _invalidate_ its cache and _load_ again: +```ts +const { user, refresh } = useUserData() ``` -src/pages/ -└── users/ - ├── index.vue - └── index@aux.vue -``` - -This creates a `components: { default: ..., aux: ... }` entry in the route config. -## Reusing / Sharing loaders +## Combining loaders -Loaders can be defined anywhere and imported and used (only in page components) when necessary. This allows to define loaders anywhere or even reuse loaders from a different page. -Any page component with named exports will be marked with a symbol to pick up any possible loader in a navigation guard. +TBD: is this necessary? At the end, this is achievable with similar composables like [logicAnd](https://vueuse.org/math/logicAnd/). It's possible to combine multiple loaders into one loader with `combineLoaders()` to not combine the results of multiple loaders into a single object but also merge `pending`, `error`, etc @@ -229,9 +270,68 @@ const { </script> ``` -### Good practice for loader organization +## Usage outside of page components -Data loaders can be imported in any component but this could also affect the way your code is code split. It's worth explaining the benefits of moving a data loader to a different file and import it in other components. +Loaders can be **only exported from pages**. That's where the navigation guard picks them up, but the page doesn't even need to use it. It can be used in any component by importing the function, even outside of the scope of the page components, by a parent. + +However, loaders can be **defined anywhere** and imported where the using the data makes most sense. This allows to define loaders anywhere or even reuse loaders from a different page: + +```ts +// src/loaders/user.ts +export const useUserData = defineLoader() +// ... +``` + +Ensure it is **exported** by page components: + +```vue +<!-- src/pages/users/[id].vue --> +<script> +export { useUserData } from '~/loaders/user.ts' +</script> +``` + +You can still use it anywhere else + +```vue +<!-- src/components/NavBar.vue --> +<script setup> +import { useUserData } from '~/loaders/user.ts' + +const { user } = useUserData() +</script> +``` + +In such scenarios, it makes more sense to move the loader to a separate file to ensure a more concrete 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 +<script lang="ts"> +import { getUserById } from '../api' +import { defineLoader } from 'vue-router' + +export const useUserData = defineLoader('/users/[id]', async (route) => { + // ^ autocompleted + const user = await getUserById(route.params.id) + // ^ typed! + // ... + return { user } +}) +</script> + +<script lang="ts" setup> +const { user, pending, error } = useUserData() +</script> +``` + +The arguments can be removed during the compilation step in production mode since they are only used for types and are actually ignored at runtime. + +## Testing pages + +TODO: How this affects testing ## Non blocking data fetching @@ -246,7 +346,7 @@ export const useUserData = defineLoader( const user = await getUserById(route.params.id) return { user } }, - { lazy: true } + { lazy: true } // 👈 marked as lazy ) </script> @@ -257,7 +357,7 @@ const { data, pending, error } = useUserData() </script> ``` -A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replaces the one being shown to make it even more confusing for the end user. +A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replacing the one being shown to make it even more confusing for the end user. Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: @@ -282,9 +382,9 @@ const { data, pending, error } = useUserData() </script> ``` -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 them are resolved. +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. -Or a function to decide upon navigation / refresh: +TBD: Conditionally block upon navigation / refresh: ```ts export const useUserData = defineLoader( @@ -299,14 +399,6 @@ export const useUserData = defineLoader( ) ``` -## Refreshing the data - -### With navigation - -### Manually - -Manually call - ## Error handling If a request fails, the user can catch the error in the loader and return it differently @@ -328,7 +420,7 @@ export const useBookCatalog = defineLoader(async () => { [More in Vue docs](https://vuejs.org/api/reactivity-advanced.html#markraw) -An alternative would be to use `shallowRef()` instead of `ref()` 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. +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. ## HMR @@ -360,18 +452,34 @@ It's possible to access a global state of when data loaders are fetching (naviga This solution is not a silver bullet but I don't think one exists because of the different data fetching strategies and how they can define the architecture of the application. - Less intuitive than just awaiting something inside `setup()` -- Can only be used in page components - Requires an extra `<script>` tag but only for views # Alternatives - Allowing a `before` and `after` hook to allow changing data after each loader call. e.g. By default the data is preserved while a new one is being fetched +- Should we return directly the necessary data instead of wrapping it with an object and always name it `data`?: + + ```vue + <script lang="ts"> + import { getUserById } from '../api' + + export const useUserData = defineLoader(async (route) => { + const user = await getUserById(route.params.id) + return user + }) + </script> + + <script setup> + const { data: user, pending, error } = useUserData() + </script> + ``` + - Adding a new `<script loader>` similar to `<script setup>`: ```vue <script lang="ts" loader="useUserData"> import { getUserById } from '@/api/users' - import { useRoute } from '@vue-router' // could be automatically imported + import { useRoute } from 'vue-router' // could be automatically imported const route = useRoute() // any variable created here is available in useLoader() @@ -379,8 +487,6 @@ This solution is not a silver bullet but I don't think one exists because of the </> <script lang="ts" setup> - import { useLoader } from '@vue-router' - const { user, pending, error } = useUserData() </> ``` @@ -406,3 +512,4 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi - Is `useNuxtApp()` usable within loaders? - Is there anything needed besides the `route` inside loaders? - Add option for placeholder data? +- What other operations might be necessary for users? From 401ab87ebca5f8783f3dd20d23c1b6eaf7265399 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Tue, 26 Jul 2022 14:57:22 +0200 Subject: [PATCH 07/25] more --- active-rfcs/0000-router-use-loader.md | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index b1282a38..72def5ea 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -7,8 +7,8 @@ List of things that haven't been added to the document yet: -- Show how to use the data loader without `@vue-router` -- Explain what `@vue-router` brings +- [x] Show how to use the data loader without ~~`@vue-router`~~ `vue-router/auto` +- [ ] Explain what ~~`@vue-router`~~ `vue-router/auto` brings # Summary @@ -57,6 +57,9 @@ const { user, pending, error, refresh } = useUserData() - `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. - **Only page components** can export loaders but loaders can be defined anywhere. +- Loaders smartly know which params/query params they depend on to force a refresh when navigating: + - Going `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was + - Going from `/users?name=fab` to `/users?name=fab#filters` checks if the current client side cache is recent enough to not fetch again # Motivation @@ -118,7 +121,7 @@ const routes = [ [LoaderSymbol]: [() => import('@/pages/UserDetails.vue')] }, }, - // named views + // Named views must include all page component lazy imports { path: '/users/:id', components: { @@ -149,8 +152,8 @@ const routes = [ ] ``` -This is **pretty verbose** and that's why it is recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to make this **completely automatic**: it will automatically generate the routes with the symbols and loaders. It will also setup the navigation guard when creating the router instance. -When using the plugin, any page component with **named exports will be marked** with a symbol to pick up any possible loader in a navigation guard. +This is **pretty verbose** and that's why it is recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to make this **completely automatic**: the plugin generates the routes with the symbols and loaders. It will also setup the navigation guard when creating the router instance. +When using the plugin, any page component with **named exports will be marked** with a symbol to pick up any possible loader in a navigation guard. The navigation guard checks every named export for loaders and _load_ them. When using vue router named views, each named view can have their own loader but note any navigation to the route will trigger **all loaders from all page components**. @@ -403,10 +406,20 @@ export const useUserData = defineLoader( If a request fails, the user can catch the error in the loader and return it differently +TODO: expand + +### 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 will abort, causing any request using it to be aborted. + ## SSR +TODO: probably need an API to allow using something else than a simple `ref()` so the data can be written anywhere and let user serialize it. + ### Avoiding double fetch on the client +TODO: an API to set the initial value of the cache before mounting the app. + ## Performance When fetching large data sets, it's convenient to mark the fetched data as _raw_ before returning it: @@ -424,7 +437,9 @@ An alternative would be to internally use `shallowRef()` instead of `ref()` insi ## HMR -When changing the `<script>` the old cache is transferred and refreshed. +When changing the `<script>` the old cache is transferred and refreshed. Worst case, the page reloads for non lazy loaders. + +TODO: expand ## Extending `defineLoader()` @@ -435,6 +450,8 @@ ideas: - Export an interface that must be implemented by a composable so external libraries can implement custom strategies (e.g. vue-query) - allow global and local config (+ types) +TODO: investigate how integrating with vue-apollo would look like + ## Global API It's possible to access a global state of when data loaders are fetching (navigation + calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). From 9e9855c6ac25e6a45992e1a60b045e9cf1d8e9ff Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Tue, 26 Jul 2022 14:58:59 +0200 Subject: [PATCH 08/25] add signal example --- active-rfcs/0000-router-use-loader.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 72def5ea..b49684c5 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -412,6 +412,13 @@ TODO: expand 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 will abort, causing any request using it to be aborted. +```ts +export const useBookCatalog = defineLoader(async (_route, { signal }) => { + const books = markRaw(await getBookCatalog({ signal })) + return { books } +}) +``` + ## SSR TODO: probably need an API to allow using something else than a simple `ref()` so the data can be written anywhere and let user serialize it. From 7997ed19c8ac9be8641523e186f9d4cdceb47000 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Thu, 28 Jul 2022 10:14:40 +0200 Subject: [PATCH 09/25] add pendingLoad --- active-rfcs/0000-router-use-loader.md | 156 +++++++++++++++++++------- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index b49684c5..4faa2a74 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -168,6 +168,74 @@ src/pages/ This creates a `components: { default: ..., aux: ... }` entry in the route config. +## `defineLoader()` + +`defineLoader()` returns a composable with the following properties: + +```ts +const useLoader = defineLoader(...) +const { + pending, // Ref<boolean> + error, // Ref<any> + refresh, // () => Promise<void> + invalidate, // () => void + pendingLoad, // () => Promise<void> | null | undefined +} = useLoader() +``` + +- `refresh()` calls `invalidate()` and then invokes the loader (an internal version that sets the `pending` flag and others) +- `invalidate()` sets the cache entry time to 0 to force a reload next time it has to +- `pendingLoad()` returns a promise that resolves when the loader is done or null if it no load is pending + +Blocking loaders (the default) also return an objects of refs of whatever is returned by the loader. + +```ts +const useLoader = defineLoader(async ({ params }) => { + const user = await getUser(params.id) + return { user } +}) + +const { + // ... same as above + user // Ref<UserData> +} = useLoader() +``` + +On the other hand, [Lazy Loaders](#non-blocking-data-fetching) return a `data` property instead: + +```ts +const useLoader = defineLoader( + async ({ params }) => { + const user = await getUser(params.id) + return { user } + }, + // 👇 this is the only difference + { lazy: true } +) + +const { + // ... same as above + data // Ref<{ user: UserData }> +} = useLoader() +``` + +Note you can also just return the user directly: + +```ts +const useLoader = defineLoader( + async ({ params }) => { + const user = await getUser(params.id) + return user + }, + { lazy: true } +) + +const { + // ... same as above + data: user // Ref<UserData> +} = useLoader() +``` + ## Parallel Fetching By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires params or nothing at all. @@ -360,47 +428,7 @@ const { data, pending, error } = useUserData() </script> ``` -A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replacing the one being shown to make it even more confusing for the end user. - -Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: - -```vue -<script lang="ts"> -import { getUserById } from '../api' - -export const useUserData = defineLoader( - async (route) => { - const user = await getUserById(route.params.id) - return { user } - }, - // block the navigation for 1 second and then let the navigation go through - { lazy: 1000 } -) -</script> - -<script setup> -// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved -const { data, pending, error } = useUserData() -// ^ Ref<{ user: User } | undefined> -</script> -``` - -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. - -TBD: Conditionally block upon navigation / refresh: - -```ts -export const useUserData = defineLoader( - loader, - // ... - { - lazy: (route) => { - // ... - return true // or a number - } - } -) -``` +See alternatives for a version of `lazy` that accepts a number/function. ## Error handling @@ -526,6 +554,52 @@ Variables could be named differently and proposals are welcome: - `pending` (same as Nuxt) -> `isPending`, `isLoading` - Rename `defineLoader()` to `defineDataFetching()` (or others) +## Advanced `lazy` + +The `lazy` flag could be extended to also accept a number or a function. I think this is too much and should therefore not be included. + +A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replacing the one being shown to make it even more confusing for the end user. + +Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: + +```vue +<script lang="ts"> +import { getUserById } from '../api' + +export const useUserData = defineLoader( + async (route) => { + const user = await getUserById(route.params.id) + return { user } + }, + // block the navigation for 1 second and then let the navigation go through + { lazy: 1000 } +) +</script> + +<script setup> +// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved +const { data, pending, error } = useUserData() +// ^ Ref<{ user: User } | undefined> +</script> +``` + +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. + +TBD: Conditionally block upon navigation / refresh: + +```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. From eba3e2eded9b9c03abab2baa52817f2521c40767 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 29 Jul 2022 10:56:44 +0200 Subject: [PATCH 10/25] more noets --- active-rfcs/0000-router-use-loader.md | 75 +++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 4faa2a74..be6b2deb 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -71,7 +71,7 @@ There are currently too many ways of handling data fetching with vue-router and - `beforeRouteEnter()`: non typed and non-ergonomic API with `next()`, requires a data store (pinia, vuex, apollo, etc) - using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) -The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. +The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer. There are features that are out of scope for this proposal but should be implementable in user land thanks to an _extendable API_: @@ -79,6 +79,14 @@ There are features that are out of scope for this proposal but should be impleme - Implement pagination - Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`, no refetch on focus, etc) +Integrating data fetching within navigations is useful for multiple reasons: + +- Ensure data is present before mounting the component +- Enables the UX pattern of letting the browser handle loading state +- Makes scrolling work out of the box when navigating between pages +- Ensure fetching happens only once +- Extremely lightweight compared to more complex fetching solutions like vue-query/tastack-query, apollo/graphql, etc + # Detailed design Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the loaders where they should **but it's not necessary**. By default, it: @@ -238,7 +246,7 @@ const { ## Parallel Fetching -By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires params or nothing at all. +By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires route params/query params or nothing at all. ## Sequential fetching @@ -246,10 +254,15 @@ Sometimes, requests depend on other fetched data (e.g. fetching additional user Call the loader inside the one that needs it, it will only be fetched once +When calling a loader we set a global flag + +or just using `T & Promise<T>` as the type. problem is that for blocking loaders, not awaiting it returns an incomplete object (no data) + ```ts export const useUserFriends = defineLoader(async (route) => { - const { user, isLoaded } = useUserData() - await isLoaded() + const { user } = await useUserData() // magically works + const { user } = await useNestedLoader(useUserData) + const friends = await getFriends(user.value.id) return { user, friends } }) @@ -285,7 +298,18 @@ TODO: expiration time of cache ## Refreshing the data -The data is refreshed automatically based on what params and query params are used within +The data is refreshed automatically based on what params and query params are used within the loader. + +Given this loader in page `/users/:id`: + +```ts +export const useUserData = defineLoader(async (route) => { + const user = await getUser(route.params.id) + return { user } +}) +``` + +Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not. ### With navigation @@ -436,6 +460,14 @@ If a request fails, the user can catch the error in the loader and return it dif TODO: expand +### 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) will cancel the navigation (same behavior as in a regular navigation guard) +- Redirection: TODO: +- Cancelling the navigation: TODO: + ### 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 will abort, causing any request using it to be aborted. @@ -489,15 +521,15 @@ TODO: investigate how integrating with vue-apollo would look like ## Global API -It's possible to access a global state of when data loaders are fetching (navigation + calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). +TBD: It's possible to access a global state of when data loaders are fetching (navigation or calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). -- `isFetchingData` -- `isNavigationFetching` +- `isFetchingData`: is any loader currently fetching data? +- `isNavigationFetching`: is navigation being hold by a loader? (implies `isFetchingData.value === true`) ## Limitations - Injections (`inject`/`provide`) cannot be used within a loader -- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released. **This is why, using composables should be avoided**. +- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released. **This is why, using composables within loaders should be avoided**. Global composables like stores or other loaders can be used inside loaders as they do not rely on a local (component-bound) scope. # Drawbacks @@ -546,6 +578,30 @@ This solution is not a silver bullet but I don't think one exists because of the Is exposing every variable a good idea? - Using Suspense to natively handle `await` within `setup()`. [See other RFC](#TODO). +- 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: + + ```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 @@ -611,3 +667,4 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi - Is there anything needed besides the `route` inside loaders? - Add option for placeholder data? - What other operations might be necessary for users? +- Is there a way to efficiently parse the exported properties in pages to filter out pages that have named exports but no loaders? From 00eab1ea23c05a35c72a853b51567ccb286140a9 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 3 Aug 2022 17:48:13 +0200 Subject: [PATCH 11/25] more --- active-rfcs/0000-router-use-loader.md | 171 +++++++++++++++++++------- 1 file changed, 125 insertions(+), 46 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index be6b2deb..125ef839 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -15,16 +15,18 @@ List of things that haven't been added to the document yet: Standarize and improve data fetching by adding helpers to be called within page components: - Automatically integrate fetching to the navigation cycle (or not by making it non-blocking) -- Automatically rerun when used params changes (avoid unnecessary fetches) +- Automatically rerun when used params/query params/hash changes (avoid unnecessary fetches) - Basic client-side caching with time-based expiration to only fetch once per navigation while using it anywhere - Provide control over loading/error states - Allow parallel or sequential data fetching (loaders that use each other) -This proposal concerns the Vue Router 4 but some examples concern [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) usage for improved DX. Especially the typed routes usage. +This proposal concerns the Vue Router 4 but some examples concern [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) usage for improved DX. Especially the typed routes usage. Note this new API doesn't require the mentioned plugin but it **greatly improves the DX**. # Basic example -We can define any amount of loaders by **exporting them** in **page components**. They return a **composable that can be used anywhere in the app**. +We can define any amount of _loaders_ by **exporting them** in **page components** (components associated to a route). They return a **composable that can be used in any component** (not only pages). + +A loader is **exported** from a non-setup `<script>` in a page component: ```vue <script lang="ts"> @@ -57,7 +59,7 @@ const { user, pending, error, refresh } = useUserData() - `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. - **Only page components** can export loaders but loaders can be defined anywhere. -- Loaders smartly know which params/query params they depend on to force a refresh when navigating: +- Loaders smartly know which params/query params/hash they depend on to force a refresh when navigating: - Going `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was - Going from `/users?name=fab` to `/users?name=fab#filters` checks if the current client side cache is recent enough to not fetch again @@ -73,33 +75,33 @@ There are currently too many ways of handling data fetching with vue-router and The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer. -There are features that are out of scope for this proposal but should be implementable in user land thanks to an _extendable API_: +There are features that are out of scope for this proposal but should be implementable in user-land thanks to an _extendable API_: - Implement a full-fledged cached API like vue-query - Implement pagination - Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`, no refetch on focus, etc) -Integrating data fetching within navigations is useful for multiple reasons: +This RFC also aims to integrate data fetching within navigations. This pattern is useful for multiple reasons: - Ensure data is present before mounting the component -- Enables the UX pattern of letting the browser handle loading state +- Enables the UX pattern of letting the browser handle loading state (aligns better with [future browser APIs](https://github.com/WICG/navigation-api)) - Makes scrolling work out of the box when navigating between pages - Ensure fetching happens only once - Extremely lightweight compared to more complex fetching solutions like vue-query/tastack-query, apollo/graphql, etc # Detailed design -Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the loaders where they should **but it's not necessary**. By default, it: +Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the loaders where they should **but it's not necessary**. At the moment, this API is implemented there as an experiment. By default, it: - Checks named exports in page components to set a meta property in the generated routes - Adds a navigation guard that resolve loaders - Implements a `defineLoader()` composable (should be moved to vue-router later) -`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when accessing +`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when calling the composable. -Limiting the loader access to only the target route, ensures that the data can be fetched when the user refresh the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params. Within loaders there is no current instance and no access to `inject`/`provide` APIs. +Limiting the loader access to only the target route, ensures that the data can be fetched when the user refresh the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params to create sharable URLs. Within loaders there is no current instance and no access to `inject`/`provide` APIs. -Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many page components export the loader or how many regular components use it. +Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many page components export the loader or how many regular components use it. It also means that all the refs (`data`, `pending`, etc) are created only once, in a detached effect scope. ## Setup @@ -163,7 +165,7 @@ const routes = [ This is **pretty verbose** and that's why it is recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to make this **completely automatic**: the plugin generates the routes with the symbols and loaders. It will also setup the navigation guard when creating the router instance. When using the plugin, any page component with **named exports will be marked** with a symbol to pick up any possible loader in a navigation guard. The navigation guard checks every named export for loaders and _load_ them. -When using vue router named views, each named view can have their own loader but note any navigation to the route will trigger **all loaders from all page components**. +When using vue router named views, each named view can have their own loaders but note any navigation to the route will trigger **all loaders from all page components**. Note: with unplugin-vue-router, a named view can be declared by appending `@name` at the end of the file name: @@ -191,16 +193,18 @@ const { } = useLoader() ``` -- `refresh()` calls `invalidate()` and then invokes the loader (an internal version that sets the `pending` flag and others) -- `invalidate()` sets the cache entry time to 0 to force a reload next time it has to -- `pendingLoad()` returns a promise that resolves when the loader is done or null if it no load is pending +- `refresh()` calls `invalidate()` and then invokes the loader (an internal version that sets the `pending` and other flags) +- `invalidate()` updates the cache entry time in order to force a reload next time it is triggered +- `pendingLoad()` returns a promise that resolves when the loader is done or `null` if it no load is pending -Blocking loaders (the default) also return an objects of refs of whatever is returned by the loader. +Blocking loaders (the default) also return a ref of each property returned by the loader. ```ts +import { getUserById } from '@/api/users' + const useLoader = defineLoader(async ({ params }) => { - const user = await getUser(params.id) - return { user } + const user = await getUserById(params.id) + return { user } // must be an object of properties }) const { @@ -212,9 +216,11 @@ const { On the other hand, [Lazy Loaders](#non-blocking-data-fetching) return a `data` property instead: ```ts +import { getUserById } from '@/api/users' + const useLoader = defineLoader( async ({ params }) => { - const user = await getUser(params.id) + const user = await getUserById(params.id) return { user } }, // 👇 this is the only difference @@ -227,12 +233,14 @@ const { } = useLoader() ``` -Note you can also just return the user directly: +Note you can also just return the user directly in _lazy loaders_: ```ts +import { getUserById } from '@/api/users' + const useLoader = defineLoader( async ({ params }) => { - const user = await getUser(params.id) + const user = await getUserById(params.id) return user }, { lazy: true } @@ -252,25 +260,25 @@ By default, loaders are executed as soon as possible, in parallel. This scenario Sometimes, requests depend on other fetched data (e.g. fetching additional user information). For these scenarios, we can simply import the other loaders and use them **within a different loader**: -Call the loader inside the one that needs it, it will only be fetched once - -When calling a loader we set a global flag - -or just using `T & Promise<T>` as the type. problem is that for blocking loaders, not awaiting it returns an incomplete object (no data) +Call **and `await`** the loader inside the one that needs it, it will only be fetched once: ```ts -export const useUserFriends = defineLoader(async (route) => { +export const useUserCommonFriends = defineLoader(async (route) => { const { user } = await useUserData() // magically works - const { user } = await useNestedLoader(useUserData) - const friends = await getFriends(user.value.id) - return { user, friends } + // fetch other data + const commonFriends = await getCommonFriends(user.value.id) + return { user, commonFriends } }) ``` +### Nested invalidation + +If `useUserData()` cache expires or gets manually invalidated, it will also automatically makes `useUserCommonFriends()` invalidate. + Note that two loaders cannot use each other as that would create a _dead lock_. -Alternatives: +### Drawbacks - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. @@ -279,7 +287,7 @@ Alternatives: ```ts import { useUserData } from '~/pages/users/[id].vue' - export const useUserFriends = defineLoader( + export const useUserCommonFriends = defineLoader( async (route, [userData]) => { const friends = await getFriends(user.value.id) return { user, friends } @@ -290,26 +298,57 @@ Alternatives: ) ``` +This can get complex with multiple pages exposing the same loader and other pages using some of this _already exported_ loaders within other loaders. But it's not an issue, loaders are still only called once: + +```ts +import { getFriends, getUserById } from '@/api/users' + +export const useUserData = defineLoader(async (route) => { + const user = await getUserById(route.params.id) + return { user } +}) + +export const useCurrentUserData(async route => { + const me = await getCurrentUser() + // imagine legacy APIs that cannot be grouped into one single fetch + const friends = await getFriends(user.value.id) + + return { me, friends } +}) + +export const useUserAndFriends = defineLoader(async (route) => { + const { user } = await useUserData() + const { friends } = await useCurrentUserData() + + const friends = await getFriends(user.value.id) + return { user, friends } +}) +``` + +In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called or optimizing them because they are only called once and share the data. + +**Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different await in between. + ## Cache and loader reuse -Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per +Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per request. TODO: expiration time of cache ## Refreshing the data -The data is refreshed automatically based on what params and query params are used within the loader. +When navigating, the data is refreshed **automatically based on what params, query params, and hash** are used within the loader. Given this loader in page `/users/:id`: ```ts export const useUserData = defineLoader(async (route) => { - const user = await getUser(route.params.id) + const user = await getUserById(route.params.id) return { user } }) ``` -Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not. +Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not unless the [cache expires](#cache-and-loader-reuse). ### With navigation @@ -333,8 +372,22 @@ export const useUserData = defineLoader( Manually call the `refresh()` function to force the loader to _invalidate_ its cache and _load_ again: -```ts +```vue +<script setup> +import { useInterval } from '@vueuse/core' +import { useUserData } from '~/pages/users/[id].vue' + const { user, refresh } = useUserData() + +// refresh the data each 10s +useInterval(refresh, 10000) +</script> + +<template> + <div> + <h1>User: {{ user.value.name }}</h1> + </div> +</template> ``` ## Combining loaders @@ -367,13 +420,13 @@ const { ## Usage outside of page components -Loaders can be **only exported from pages**. That's where the navigation guard picks them up, but the page doesn't even need to use it. It can be used in any component by importing the function, even outside of the scope of the page components, by a parent. +Loaders can be **only exported from pages**. That's where the navigation guard picks them up, but the page doesn't even need to use it. It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, by a parent. -However, loaders can be **defined anywhere** and imported where the using the data makes most sense. This allows to define loaders anywhere or even reuse loaders from a different page: +However, loaders can be **defined anywhere** and imported where using the data makes sense. This allows to define loaders in a separate `/loaders` folder and reuse them across pages: ```ts // src/loaders/user.ts -export const useUserData = defineLoader() +export const useUserData = defineLoader(...) // ... ``` @@ -386,7 +439,7 @@ export { useUserData } from '~/loaders/user.ts' </script> ``` -You can still use it anywhere else +You can still use it anywhere else: ```vue <!-- src/components/NavBar.vue --> @@ -452,6 +505,12 @@ const { data, pending, error } = useUserData() </script> ``` +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. + +TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. Useful for SSR. + +TODO: transform a loader into a lazy version of it + See alternatives for a version of `lazy` that accepts a number/function. ## Error handling @@ -465,12 +524,32 @@ TODO: expand 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) will cancel the navigation (same behavior as in a regular navigation guard) -- Redirection: TODO: -- Cancelling the navigation: TODO: +- Redirection: TODO: use a helper? `return redirectTo(...)` / `navigateTo()` +- Cancelling the navigation: TODO: use a helper? `return abortNavigation()`. Other names? `abort(err?: any)` +- Other possibility: having one single `next()` function (or other name) + +```ts +export const useUserData = defineLoader( + async ({ params, path ,query, hash }, { navigateTo, abortNavigation }) => { + try { + const user = await getUserById(params.id) + + return { user } + } catch (error) { + if (error.status === 404) { + navigateTo({ name: 'not-found', params: { pathMatch: } }) + } else { + throw error // same as abortNavigation(error) + } + } + abortNavigation() + } +) +``` ### 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 will abort, causing any request using it to be aborted. +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 will abort, causing any request using it to be aborted as well. ```ts export const useBookCatalog = defineLoader(async (_route, { signal }) => { @@ -533,10 +612,10 @@ TBD: It's possible to access a global state of when data loaders are fetching (n # Drawbacks -This solution is not a silver bullet but I don't think one exists because of the different data fetching strategies and how they can define the architecture of the application. +This solution is not a silver bullet but I don't think one exists because of the different data fetching strategies and how they can define the architecture of the application and its UX. - Less intuitive than just awaiting something inside `setup()` -- Requires an extra `<script>` tag but only for views +- Requires an extra `<script>` tag but only for views. Is it feasible to add a macro `definePageLoader()`? # Alternatives From 4d1b2fc90e59c78da0bd168123acc592338cf72e Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Tue, 9 Aug 2022 14:17:01 +0200 Subject: [PATCH 12/25] only data --- active-rfcs/0000-router-use-loader.md | 140 ++++++++++++++------------ 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 125ef839..5deea434 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -197,55 +197,16 @@ const { - `invalidate()` updates the cache entry time in order to force a reload next time it is triggered - `pendingLoad()` returns a promise that resolves when the loader is done or `null` if it no load is pending -Blocking loaders (the default) also return a ref of each property returned by the loader. +Blocking loaders (the default) also return a ref of the returned data by the loader. ```ts import { getUserById } from '@/api/users' const useLoader = defineLoader(async ({ params }) => { const user = await getUserById(params.id) - return { user } // must be an object of properties + return user // can be anything }) -const { - // ... same as above - user // Ref<UserData> -} = useLoader() -``` - -On the other hand, [Lazy Loaders](#non-blocking-data-fetching) return a `data` property instead: - -```ts -import { getUserById } from '@/api/users' - -const useLoader = defineLoader( - async ({ params }) => { - const user = await getUserById(params.id) - return { user } - }, - // 👇 this is the only difference - { lazy: true } -) - -const { - // ... same as above - data // Ref<{ user: UserData }> -} = useLoader() -``` - -Note you can also just return the user directly in _lazy loaders_: - -```ts -import { getUserById } from '@/api/users' - -const useLoader = defineLoader( - async ({ params }) => { - const user = await getUserById(params.id) - return user - }, - { lazy: true } -) - const { // ... same as above data: user // Ref<UserData> @@ -264,11 +225,11 @@ Call **and `await`** the loader inside the one that needs it, it will only be fe ```ts export const useUserCommonFriends = defineLoader(async (route) => { - const { user } = await useUserData() // magically works + const { data: user } = await useUserData() // magically works // fetch other data const commonFriends = await getCommonFriends(user.value.id) - return { user, commonFriends } + return { ...user.value, commonFriends } }) ``` @@ -287,10 +248,10 @@ Note that two loaders cannot use each other as that would create a _dead lock_. ```ts import { useUserData } from '~/pages/users/[id].vue' - export const useUserCommonFriends = defineLoader( + export const useUserFriends = defineLoader( async (route, [userData]) => { const friends = await getFriends(user.value.id) - return { user, friends } + return { ...userData.value, friends } }, { waitFor: [useUserData] @@ -305,7 +266,7 @@ import { getFriends, getUserById } from '@/api/users' export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) - return { user } + return user }) export const useCurrentUserData(async route => { @@ -313,21 +274,31 @@ export const useCurrentUserData(async route => { // imagine legacy APIs that cannot be grouped into one single fetch const friends = await getFriends(user.value.id) - return { me, friends } + return { ...me, friends } }) -export const useUserAndFriends = defineLoader(async (route) => { - const { user } = await useUserData() - const { friends } = await useCurrentUserData() +export const useUserCommonFriends = defineLoader(async (route) => { + const { data: user } = await useUserData() + const { data: me } = await useCurrentUserData() - const friends = await getFriends(user.value.id) - return { user, friends } + const friends = await getCommonFriends(user.value.id, me.value.id) + return { ...me.value, commonFriends: { with: user.value, friends } } }) ``` In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called or optimizing them because they are only called once and share the data. -**Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different await in between. +**Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different regular `await` in between, it has to be wrapped with a `withDataContext()`: + +```ts +export const useUserCommonFriends = defineLoader(async (route) => { + const { data: user } = await useUserData() + await withContext(functionThatReturnsAPromise()) + const { data: me } = await useCurrentUserData() + + // ... +}) +``` ## Cache and loader reuse @@ -344,7 +315,7 @@ Given this loader in page `/users/:id`: ```ts export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) - return { user } + return user }) ``` @@ -357,7 +328,6 @@ TODO: maybe an option `refresh`: ```ts export const useUserData = defineLoader( async (route) => { - await isLoaded() const friends = await getFriends(user.value.id) return { user, friends } }, @@ -377,7 +347,7 @@ Manually call the `refresh()` function to force the loader to _invalidate_ its c import { useInterval } from '@vueuse/core' import { useUserData } from '~/pages/users/[id].vue' -const { user, refresh } = useUserData() +const { data: user, refresh } = useUserData() // refresh the data each 10s useInterval(refresh, 10000) @@ -406,8 +376,7 @@ export const useCombinedLoader = combineLoaders(useUserData, usePostData) <script setup> const { - user, - post, + data, // both pending pending, // any error that happens @@ -415,6 +384,7 @@ const { // refresh both refresh } = useCombinedLoader() +const { user, post } = toRefs(data.value) </script> ``` @@ -446,7 +416,7 @@ You can still use it anywhere else: <script setup> import { useUserData } from '~/loaders/user.ts' -const { user } = useUserData() +const { data: user } = useUserData() </script> ``` @@ -466,12 +436,12 @@ export const useUserData = defineLoader('/users/[id]', async (route) => { const user = await getUserById(route.params.id) // ^ typed! // ... - return { user } + return user }) </script> <script lang="ts" setup> -const { user, pending, error } = useUserData() +const { data: user, pending, error } = useUserData() </script> ``` @@ -492,7 +462,7 @@ import { getUserById } from '../api' export const useUserData = defineLoader( async (route) => { const user = await getUserById(route.params.id) - return { user } + return user }, { lazy: true } // 👈 marked as lazy ) @@ -500,7 +470,7 @@ export const useUserData = defineLoader( <script setup> // in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved -const { data, pending, error } = useUserData() +const { data: user, pending, error } = useUserData() // ^ Ref<{ user: User } | undefined> </script> ``` @@ -534,7 +504,7 @@ export const useUserData = defineLoader( try { const user = await getUserById(params.id) - return { user } + return user } catch (error) { if (error.status === 404) { navigateTo({ name: 'not-found', params: { pathMatch: } }) @@ -542,7 +512,7 @@ export const useUserData = defineLoader( throw error // same as abortNavigation(error) } } - abortNavigation() + throw abortNavigation() } ) ``` @@ -554,7 +524,7 @@ The loader receives in a second argument access to an [`AbortSignal`](https://de ```ts export const useBookCatalog = defineLoader(async (_route, { signal }) => { const books = markRaw(await getBookCatalog({ signal })) - return { books } + return books }) ``` @@ -573,7 +543,7 @@ When fetching large data sets, it's convenient to mark the fetched data as _raw_ ```ts export const useBookCatalog = defineLoader(async () => { const books = markRaw(await getBookCatalog()) - return { books } + return books }) ``` @@ -598,6 +568,29 @@ ideas: TODO: investigate how integrating with vue-apollo would look like +### Nuxt.js + +```ts +const useUserData = defineLoader( + async (route) => { + const user = await getUserById(route.params.id) + return user + }, + { + createDataEntry() { + return { + data: useState('user', ref()), + pending: ref(false), + error: ref(), + isReady: false, + when: Date.now() + } + }, + updateDataEntry(entry, data) {} + } +) +``` + ## Global API TBD: It's possible to access a global state of when data loaders are fetching (navigation or calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). @@ -620,6 +613,19 @@ This solution is not a silver bullet but I don't think one exists because of the # Alternatives - Allowing a `before` and `after` hook to allow changing data after each loader call. e.g. By default the data is preserved while a new one is being fetched +- Allowing blocking data loaders to return objects of properties: + + ```ts + export const useUserData = defineLoader(async (route) => { + const user = await getUserById(route.params.id) + 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. + - Should we return directly the necessary data instead of wrapping it with an object and always name it `data`?: ```vue From eaac648e24d36fae358609ad0b31512b91c553b2 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 10 Aug 2022 23:21:50 +0200 Subject: [PATCH 13/25] ssr updates --- active-rfcs/0000-router-use-loader.md | 108 ++++++++++++++++++++------ 1 file changed, 83 insertions(+), 25 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 5deea434..37a859e0 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -38,7 +38,7 @@ export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) // ... // return anything you want to expose - return { user } + return user }) // Optional: define other component options @@ -49,9 +49,9 @@ export default defineComponent({ </script> <script lang="ts" setup> -// find `user` and some other properties -const { user, pending, error, refresh } = useUserData() -// user is always present, pending changes when going from '/users/2' to '/users/3' +// find the user as `data` and some other properties +const { data: user, pending, error, refresh } = useUserData() +// data is always present, pending changes when going from '/users/2' to '/users/3' </script> ``` @@ -63,6 +63,15 @@ const { user, pending, error, refresh } = useUserData() - Going `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was - Going from `/users?name=fab` to `/users?name=fab#filters` checks if the current client side cache is recent enough to not fetch again +The simplest loaders are also one-liners: + +```ts +export const useBookCollection = defineLoader(fetchBookCollection) +// function fetchBookCollection(): Promise<Books[]> +``` + +During the RFC, we will often use a bit longer examples to make things easier to follow by anyone. + # Motivation There are currently too many ways of handling data fetching with vue-router and all of them have problems: @@ -169,7 +178,7 @@ When using vue router named views, each named view can have their own loaders bu Note: with unplugin-vue-router, a named view can be declared by appending `@name` at the end of the file name: -``` +```text src/pages/ └── users/ ├── index.vue @@ -185,6 +194,7 @@ This creates a `components: { default: ..., aux: ... }` entry in the route confi ```ts const useLoader = defineLoader(...) const { + data, // Ref<T> T being the return type of the function passed to `defineLoader()` pending, // Ref<boolean> error, // Ref<any> refresh, // () => Promise<void> @@ -197,12 +207,12 @@ const { - `invalidate()` updates the cache entry time in order to force a reload next time it is triggered - `pendingLoad()` returns a promise that resolves when the loader is done or `null` if it no load is pending -Blocking loaders (the default) also return a ref of the returned data by the loader. +Blocking loaders (the default) also return a ref of the returned data by the loader. Usually, it makes sense to rename this `data` property: ```ts import { getUserById } from '@/api/users' -const useLoader = defineLoader(async ({ params }) => { +const useUserData = defineLoader(async ({ params }) => { const user = await getUserById(params.id) return user // can be anything }) @@ -210,7 +220,7 @@ const useLoader = defineLoader(async ({ params }) => { const { // ... same as above data: user // Ref<UserData> -} = useLoader() +} = useUserData() ``` ## Parallel Fetching @@ -221,9 +231,12 @@ By default, loaders are executed as soon as possible, in parallel. This scenario Sometimes, requests depend on other fetched data (e.g. fetching additional user information). For these scenarios, we can simply import the other loaders and use them **within a different loader**: -Call **and `await`** the loader inside the one that needs it, it will only be fetched once: +Call **and `await`** the loader inside the one that needs it, it will only be fetched once no matter how many times it is called used: ```ts +// import the loader for user information +import { useUserData } from '@/loaders/users' + export const useUserCommonFriends = defineLoader(async (route) => { const { data: user } = await useUserData() // magically works @@ -243,7 +256,7 @@ Note that two loaders cannot use each other as that would create a _dead lock_. - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. -- Pass an array of loaders to the loader that needs them and use let it retrieve them through an argument: +- Another alternative is to pass an array of loaders to the loader that needs them and use let it retrieve them through an argument, but it feels less ergonomic: ```ts import { useUserData } from '~/pages/users/[id].vue' @@ -254,6 +267,7 @@ Note that two loaders cannot use each other as that would create a _dead lock_. return { ...userData.value, friends } }, { + // explicit dependencies waitFor: [useUserData] } ) @@ -300,11 +314,17 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` +This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with a plugin. + ## Cache and loader reuse -Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per request. +Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per request. It's a very simple time based expiration cache that defaults to 5s. When a loader is called, it will wait 5s before calling the loader function again. This can be changed with the `cacheTime` option: -TODO: expiration time of cache +```ts +defineLoader(..., { cacheTime: 1000 * 60 * 5 }) // 5 minutes +defineLoader(..., { cacheTime: 0 }) // No cache (still avoids calling the loader twice in parallel) +defineLoader(..., { cacheTime: Infinity }) // Cache forever +``` ## Refreshing the data @@ -321,7 +341,7 @@ export const useUserData = defineLoader(async (route) => { Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not unless the [cache expires](#cache-and-loader-reuse). -### With navigation +<!-- ### With navigation TODO: maybe an option `refresh`: @@ -336,7 +356,7 @@ export const useUserData = defineLoader( refresh: (route) => !!route.query.force } ) -``` +``` --> ### Manually @@ -349,7 +369,7 @@ import { useUserData } from '~/pages/users/[id].vue' const { data: user, refresh } = useUserData() -// refresh the data each 10s +// refresh the data every 10s useInterval(refresh, 10000) </script> @@ -392,7 +412,7 @@ const { user, post } = toRefs(data.value) Loaders can be **only exported from pages**. That's where the navigation guard picks them up, but the page doesn't even need to use it. It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, by a parent. -However, loaders can be **defined anywhere** and imported where using the data makes sense. This allows to define loaders in a separate `/loaders` folder and reuse them across pages: +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 @@ -432,7 +452,7 @@ import { getUserById } from '../api' import { defineLoader } from 'vue-router' export const useUserData = defineLoader('/users/[id]', async (route) => { - // ^ autocompleted + // ^ autocompleted by unplugin-vue-router ✨ const user = await getUserById(route.params.id) // ^ typed! // ... @@ -469,17 +489,16 @@ export const useUserData = defineLoader( </script> <script setup> -// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved +// `user.value` can be `undefined` const { data: user, pending, error } = useUserData() -// ^ Ref<{ user: User } | undefined> +// ^ Ref<User | undefined> </script> ``` -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. +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. -TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. Useful for SSR. - -TODO: transform a loader into a lazy version of it +TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. Useful for SSR. Otherwise we could always ignore lazy loaders in SSSR. Do we need both? +TODO: transform a loader into a lazy version of it. See alternatives for a version of `lazy` that accepts a number/function. @@ -530,11 +549,50 @@ export const useBookCatalog = defineLoader(async (_route, { signal }) => { ## SSR -TODO: probably need an API to allow using something else than a simple `ref()` so the data can be written anywhere and let user serialize it. +To support SSR we need to do two things: + +- Pass a `key` to each loader so that it can be serialized into an object later. Would an array work? I don't think the order of execution is guaranteed. +- On the client side, pass the initial state to `setupDataFetchingGuard()`. The initial state is used once and discarded afterwards. + +```ts +export const useBookCollection = defineLoader( + async () => { + const books = await fetchBookCollection() + return books + }, + { key: 'bookCollection' } +) +``` + +The configuration of `setupDataFetchingGuard()` depends on the SSR configuration: + +```ts +import { ViteSSG } from 'vite-ssg' +import { setupDataFetchingGuard } from 'vue-router' +import App from './App.vue' + +export const createApp = ViteSSG( + App, + { routes }, + async ({ router, isClient, initialState }) => { + // fetchedData will be populated during navigation + const fetchedData = setupDataFetchingGuard( + router, + isClient ? initialState.vueRouter : undefined + ) + + if (!isClient) { + initialState.vueRouter = fetchedData + } + } +) +``` + +Note that `setupDataFetchingGuard()` **must be called before `app.use(router)`**. ### Avoiding double fetch on the client -TODO: an API to set the initial value of the cache before mounting the app. +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 nested loaders **aren't executed either**. This could be confusing if we need to put side effects in data loaders. TBD: do we need to support this use case? We could allow it by having a `force` option on the loader and passing the initial state in the second argument of the loader. ## Performance From 6b7cd8aa7a6433f5d30fd44a46eaaa2dc42e1f07 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Tue, 23 Aug 2022 18:48:59 +0200 Subject: [PATCH 14/25] updates --- active-rfcs/0000-router-use-loader.md | 104 +++++++++++++++----------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 37a859e0..7d0e003c 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -8,7 +8,7 @@ List of things that haven't been added to the document yet: - [x] Show how to use the data loader without ~~`@vue-router`~~ `vue-router/auto` -- [ ] Explain what ~~`@vue-router`~~ `vue-router/auto` brings +- [x] Explain what ~~`@vue-router`~~ `vue-router/auto` brings # Summary @@ -58,19 +58,19 @@ const { data: user, pending, error, refresh } = useUserData() - `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. -- **Only page components** can export loaders but loaders can be defined anywhere. +- **Only page components** can export loaders but **loaders can be defined anywhere**. - Loaders smartly know which params/query params/hash they depend on to force a refresh when navigating: - - Going `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was + - Going from `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was - Going from `/users?name=fab` to `/users?name=fab#filters` checks if the current client side cache is recent enough to not fetch again -The simplest loaders are also one-liners: +The simplest of loaders can be defined in just one line and types will be automatically inferred: ```ts export const useBookCollection = defineLoader(fetchBookCollection) // function fetchBookCollection(): Promise<Books[]> ``` -During the RFC, we will often use a bit longer examples to make things easier to follow by anyone. +Note that this syntax will intentionally be avoided in the RFC. Instead, we will often use slightly longer examples to make things easier to follow by anyone. # Motivation @@ -88,9 +88,9 @@ There are features that are out of scope for this proposal but should be impleme - Implement a full-fledged cached API like vue-query - Implement pagination -- Automatically refetch data when **outside of navigations** (e.g. no `refetchInterval`, no refetch on focus, etc) +- Automatically refetch data when **outside of navigations** (e.g. there is no intention to implement advanced APIs such as `refetchInterval`, refetch on focus, etc) -This RFC also aims to integrate data fetching within navigations. This pattern is useful for multiple reasons: +This RFC also aims to integrate data fetching within navigations while still allowing you to avoid it. This pattern is useful for multiple reasons: - Ensure data is present before mounting the component - Enables the UX pattern of letting the browser handle loading state (aligns better with [future browser APIs](https://github.com/WICG/navigation-api)) @@ -100,7 +100,7 @@ This RFC also aims to integrate data fetching within navigations. This pattern i # Detailed design -Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the loaders where they should **but it's not necessary**. At the moment, this API is implemented there as an experiment. By default, it: +Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the proper meta fields **but it's not necessary**. At the moment, this API is implemented in that plugin as an experiment. By default, it: - Checks named exports in page components to set a meta property in the generated routes - Adds a navigation guard that resolve loaders @@ -194,7 +194,7 @@ This creates a `components: { default: ..., aux: ... }` entry in the route confi ```ts const useLoader = defineLoader(...) const { - data, // Ref<T> T being the return type of the function passed to `defineLoader()` + data, // Ref<T> T being the awaited return type of the function passed to `defineLoader()` pending, // Ref<boolean> error, // Ref<any> refresh, // () => Promise<void> @@ -205,7 +205,7 @@ const { - `refresh()` calls `invalidate()` and then invokes the loader (an internal version that sets the `pending` and other flags) - `invalidate()` updates the cache entry time in order to force a reload next time it is triggered -- `pendingLoad()` returns a promise that resolves when the loader is done or `null` if it no load is pending +- `pendingLoad()` returns a promise that resolves when the loader is done or `null` if no load is currently pending Blocking loaders (the default) also return a ref of the returned data by the loader. Usually, it makes sense to rename this `data` property: @@ -248,7 +248,7 @@ export const useUserCommonFriends = defineLoader(async (route) => { ### Nested invalidation -If `useUserData()` cache expires or gets manually invalidated, it will also automatically makes `useUserCommonFriends()` invalidate. +Since `useUserData()` loader calls `useUserCommonFriends()`, if `useUserData()`'s cache expires or gets manually invalidated, it will also automatically invalidate `useUserCommonFriends()`. Note that two loaders cannot use each other as that would create a _dead lock_. @@ -256,7 +256,7 @@ Note that two loaders cannot use each other as that would create a _dead lock_. - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. -- Another alternative is to pass an array of loaders to the loader that needs them and use let it retrieve them through an argument, but it feels less ergonomic: +- Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels less ergonomic: ```ts import { useUserData } from '~/pages/users/[id].vue' @@ -273,7 +273,7 @@ Note that two loaders cannot use each other as that would create a _dead lock_. ) ``` -This can get complex with multiple pages exposing the same loader and other pages using some of this _already exported_ loaders within other loaders. But it's not an issue, loaders are still only called once: +This can get complex with multiple pages exposing the same loader and other pages using some of this _already exported_ loaders within other loaders. But it's not an issue, **the user shouldn't need to handle anything differently**, loaders are still only called once: ```ts import { getFriends, getUserById } from '@/api/users' @@ -314,11 +314,13 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` -This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with a plugin. +This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with an eslint plugin. ## Cache and loader reuse -Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance**. This aligns with the recommendation of using one router instance per request. It's a very simple time based expiration cache that defaults to 5s. When a loader is called, it will wait 5s before calling the loader function again. This can be changed with the `cacheTime` option: +Each loader has its own cache and it's **not shared** across multiple application instances **as long as they use a different `router` instance** (the `router` is internally used as the key of a `WeakMap()`). This aligns with the recommendation of using one router instance per request in the ecosystem and usually one won't even need to know about this. + +The cache is a very simple time based expiration cache that defaults to 5 seconds. When a loader is called, it will wait 5s before calling the loader function again. This can be changed with the `cacheTime` option: ```ts defineLoader(..., { cacheTime: 1000 * 60 * 5 }) // 5 minutes @@ -339,7 +341,7 @@ export const useUserData = defineLoader(async (route) => { }) ``` -Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not unless the [cache expires](#cache-and-loader-reuse). +Going from `/users/1` to `/users/2` will refresh the data but going from `/users/2` to `/users/2#projects` will not unless the cache expires or is manually invalidated. <!-- ### With navigation @@ -358,7 +360,7 @@ export const useUserData = defineLoader( ) ``` --> -### Manually +### Manually refreshing the data Manually call the `refresh()` function to force the loader to _invalidate_ its cache and _load_ again: @@ -380,7 +382,9 @@ useInterval(refresh, 10000) </template> ``` -## Combining loaders +<!-- + + ## Combining loaders TBD: is this necessary? At the end, this is achievable with similar composables like [logicAnd](https://vueuse.org/math/logicAnd/). @@ -407,10 +411,11 @@ const { const { user, post } = toRefs(data.value) </script> ``` + --> ## Usage outside of page components -Loaders can be **only exported from pages**. That's where the navigation guard picks them up, but the page doesn't even need to use it. It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, by a parent. +Loaders can be **only exported from pages**. That's where the navigation guard picks them up, **but the page doesn't even need to use it**. It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, 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: @@ -465,11 +470,7 @@ const { data: user, pending, error } = useUserData() </script> ``` -The arguments can be removed during the compilation step in production mode since they are only used for types and are actually ignored at runtime. - -## Testing pages - -TODO: How this affects testing +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 @@ -497,46 +498,65 @@ const { data: user, pending, error } = useUserData() 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. -TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. Useful for SSR. Otherwise we could always ignore lazy loaders in SSSR. Do we need both? -TODO: transform a loader into a lazy version of it. - -See alternatives for a version of `lazy` that accepts a number/function. - -## Error handling - -If a request fails, the user can catch the error in the loader and return it differently - -TODO: expand +TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. 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? +TODO: transform a loader into a lazy version of it: `const useUserDataLazy = asLazyLoader(useUserData)` -### Controlling the navigation +## 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) will cancel the navigation (same behavior as in a regular navigation guard) +- Thrown errors (or rejected Promises) will cancel the navigation (same behavior as in a regular navigation guard) and get intercepted by [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) - Redirection: TODO: use a helper? `return redirectTo(...)` / `navigateTo()` - Cancelling the navigation: TODO: use a helper? `return abortNavigation()`. Other names? `abort(err?: any)` -- Other possibility: having one single `next()` function (or other name) +- Other possibility: having one single `next()` function (or other name): rather not because can be called multiple times ```ts +import { NavigationResult } from 'vue-router' + export const useUserData = defineLoader( - async ({ params, path ,query, hash }, { navigateTo, abortNavigation }) => { + async ({ params, path ,query, hash }) => { try { const user = await getUserById(params.id) return user } catch (error) { if (error.status === 404) { - navigateTo({ name: 'not-found', params: { pathMatch: } }) + return new NavigationResult({ name: 'not-found', params: { pathMatch: } } + ) } else { - throw error // same as abortNavigation(error) + throw error // same result as new NavigationResult(error) } } - throw abortNavigation() } ) ``` -### AbortSignal +`new NavigationResult()` accepts anything that [can be returned in a navigation guard](https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards). + +### 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 `setupDataFetchingGuard()`: + +```ts +setupDataFetchingGuard(router, { + selectNavigationResult(results) { + // results is an array of the unwrapped results passed to `new NavigationResult()` + return results.find((result) => result.name === 'not-found') + } +}) +``` + +This allows you to define any priority you want as well as select + +## Error handling + +Any error thrown within a loader will make the navigation fail. + +If a request fails, the user can catch the error in the loader and return it differently + +TODO: expand + +## 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 will abort, causing any request using it to be aborted as well. From a38f640196a3bfc017997d85acff3fd38a6654ac Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 24 Aug 2022 11:48:35 +0200 Subject: [PATCH 15/25] setup, errors, redirects --- active-rfcs/0000-router-use-loader.md | 118 ++++++++++++++++---------- 1 file changed, 75 insertions(+), 43 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 7d0e003c..6185410c 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -223,6 +223,44 @@ const { } = useUserData() ``` +## `setupDataFetchingGuard()` + +`setupDataFetchingGuard()` setups a navigation guard that handles all the loaders. In SPA, its usage is very simple: + +```ts +setupDataFetchingGuard(router) +``` + +You can also pass a second argument for some global options. In [SSR](#ssr), you can also retrieve the fetchedData as its returned value: + +```ts +const fetchedData = setupDataFetchingGuard( + router, // the router instance for the app + { + // hook triggered before each loader is ran + async beforeLoad(route) { + // route is the target route passed to a loader + // Ensures pinia stores are called with the right context + setActivePinia(pinia) + + // all loaders will await for this to be run before executing + await someOperation + }, + + // initial data for SSR, see the section below + initialData: { + // ... + }, + + // returns the result of the navigation that should be returned by the navigation guard + // see the section below for more details + selectNavigationResult(results) { + return results[0] + } + } +) +``` + ## Parallel Fetching By default, loaders are executed as soon as possible, in parallel. This scenario works well for most use cases where data fetching only requires route params/query params or nothing at all. @@ -533,6 +571,11 @@ export const useUserData = defineLoader( `new NavigationResult()` accepts 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` + ### 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 `setupDataFetchingGuard()`: @@ -550,11 +593,9 @@ This allows you to define any priority you want as well as select ## Error handling -Any error thrown within a loader will make the navigation fail. - -If a request fails, the user can catch the error in the loader and return it differently +Any error thrown within a loader will make the navigation fail. Differently from returning a value, a throw value will immediately reject the Data fetching Promise and `selectNavigationResult()` isn't called. -TODO: expand +Errors are handled by the [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) and can be intercepted by the `onError` hook. ## AbortSignal @@ -567,6 +608,8 @@ export const useBookCatalog = defineLoader(async (_route, { signal }) => { }) ``` +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: @@ -584,23 +627,28 @@ export const useBookCollection = defineLoader( ) ``` -The configuration of `setupDataFetchingGuard()` depends on the SSR configuration: +The configuration of `setupDataFetchingGuard()` depends on the SSR configuration, here is an example with vite-ssg: ```ts import { ViteSSG } from 'vite-ssg' import { setupDataFetchingGuard } 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 = setupDataFetchingGuard( - router, - isClient ? initialState.vueRouter : undefined - ) - + const fetchedData = setupDataFetchingGuard(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 } @@ -608,15 +656,17 @@ export const createApp = ViteSSG( ) ``` -Note that `setupDataFetchingGuard()` **must be called before `app.use(router)`**. +Note that `setupDataFetchingGuard()` **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 nested loaders **aren't executed either**. This could be confusing if we need to put side effects in data loaders. TBD: do we need to support this use case? We could allow it by having a `force` option on the loader and passing the initial state in the second argument of the loader. +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. + +<!-- TBD: do we need to support this use case? We could allow it by having a `force` option on the loader and passing the initial state in the second argument of the loader. --> ## Performance -When fetching large data sets, it's convenient to mark the fetched data as _raw_ before returning it: +**When fetching large data sets**, it's convenient to mark the fetched data as _raw_ before returning it: ```ts export const useBookCatalog = defineLoader(async () => { @@ -646,47 +696,29 @@ ideas: TODO: investigate how integrating with vue-apollo would look like -### Nuxt.js - -```ts -const useUserData = defineLoader( - async (route) => { - const user = await getUserById(route.params.id) - return user - }, - { - createDataEntry() { - return { - data: useState('user', ref()), - pending: ref(false), - error: ref(), - isReady: false, - when: Date.now() - } - }, - updateDataEntry(entry, data) {} - } -) -``` - ## Global API -TBD: It's possible to access a global state of when data loaders are fetching (navigation or calling `refresh()`) as well as when the data fetching navigation guard is running (only when navigating). +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<boolean>`: is any loader currently fetching data? e.g. calling the `refresh()` method of a loader +- `isNavigationFetching: Ref<boolean>`: is navigation being hold by a loader? (implies `isFetchingData.value === true`). Calling the `refresh()` method of a loader doesn't change this state. -- `isFetchingData`: is any loader currently fetching data? -- `isNavigationFetching`: is navigation being hold by a loader? (implies `isFetchingData.value === true`) +TBD: is this worth it? Are any other functions needed? ## Limitations - Injections (`inject`/`provide`) cannot be used within a loader -- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released. **This is why, using composables within loaders should be avoided**. Global composables like stores or other loaders can be used inside loaders as they do not rely on a local (component-bound) scope. +- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released + - 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 +- Global composables like pinia might need special handling (e.g. calling `setActivePinia(pinia)` in a [`beforeLoad()` hook](#todo)) # Drawbacks -This solution is not a silver bullet but I don't think one exists because of the different data fetching strategies and how they can define the architecture of the application and its UX. +This solution is not a silver bullet but I don't think one exists 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. -- Less intuitive than just awaiting something inside `setup()` -- Requires an extra `<script>` tag but only for views. Is it feasible to add a macro `definePageLoader()`? +- Less intuitive than just awaiting something inside `setup()`: but it's still possible to await inside `setup()`, it's just not connected to the navigation +- Requires an extra `<script>` tag but only for views. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function # Alternatives From 10e38f708b482c800b699bf3535f8afb044d19e8 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 24 Aug 2022 15:02:10 +0200 Subject: [PATCH 16/25] prepared to show --- active-rfcs/0000-router-use-loader.md | 141 +++++++++++--------------- 1 file changed, 58 insertions(+), 83 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 6185410c..a0db8679 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -7,12 +7,14 @@ List of things that haven't been added to the document yet: -- [x] Show how to use the data loader without ~~`@vue-router`~~ `vue-router/auto` -- [x] Explain what ~~`@vue-router`~~ `vue-router/auto` brings +- [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 -Standarize and improve data fetching by adding helpers to be called within page components: +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: - Automatically integrate fetching to the navigation cycle (or not by making it non-blocking) - Automatically rerun when used params/query params/hash changes (avoid unnecessary fetches) @@ -90,7 +92,7 @@ There are features that are out of scope for this proposal but should be impleme - Implement pagination - Automatically refetch data when **outside of navigations** (e.g. there is no intention to implement advanced APIs such as `refetchInterval`, refetch on focus, etc) -This RFC also aims to integrate data fetching within navigations while still allowing you to avoid it. This pattern is useful for multiple reasons: +This RFC also aims to integrate data fetching within navigations while still not forcing you to block navigation with data fetching. This pattern is useful for multiple reasons: - Ensure data is present before mounting the component - Enables the UX pattern of letting the browser handle loading state (aligns better with [future browser APIs](https://github.com/WICG/navigation-api)) @@ -100,10 +102,10 @@ This RFC also aims to integrate data fetching within navigations while still all # Detailed design -Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the proper meta fields **but it's not necessary**. At the moment, this API is implemented in that plugin as an experiment. By default, it: +Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the proper meta fields **but it's not necessary**. At the moment, this API is implemented in that plugin as an experiment, check the [Experimental Data fetching](https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching). By default, it: - Checks named exports in page components to set a meta property in the generated routes -- Adds a navigation guard that resolve loaders +- Adds a navigation guard that resolve loaders (should be moved to vue-router later) - Implements a `defineLoader()` composable (should be moved to vue-router later) `defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when calling the composable. @@ -253,7 +255,7 @@ const fetchedData = setupDataFetchingGuard( }, // returns the result of the navigation that should be returned by the navigation guard - // see the section below for more details + // see the section below for more details about "Navigation Results" selectNavigationResult(results) { return results[0] } @@ -276,6 +278,8 @@ Call **and `await`** the loader inside the one that needs it, it will only be fe import { useUserData } from '@/loaders/users' export const useUserCommonFriends = defineLoader(async (route) => { + // loaders must be awaited inside other loaders + // . ⤵ const { data: user } = await useUserData() // magically works // fetch other data @@ -290,27 +294,6 @@ Since `useUserData()` loader calls `useUserCommonFriends()`, if `useUserData()`' Note that two loaders cannot use each other as that would create a _dead lock_. -### Drawbacks - -- Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. - -- Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels less ergonomic: - - ```ts - import { useUserData } from '~/pages/users/[id].vue' - - export const useUserFriends = defineLoader( - async (route, [userData]) => { - const friends = await getFriends(user.value.id) - return { ...userData.value, friends } - }, - { - // explicit dependencies - waitFor: [useUserData] - } - ) - ``` - This can get complex with multiple pages exposing the same loader and other pages using some of this _already exported_ loaders within other loaders. But it's not an issue, **the user shouldn't need to handle anything differently**, loaders are still only called once: ```ts @@ -338,7 +321,7 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` -In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called or optimizing them because they are only called once and share the data. +In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called nor try optimizing them because they are only called once and share the data. **Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different regular `await` in between, it has to be wrapped with a `withDataContext()`: @@ -352,7 +335,7 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` -This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with an eslint plugin. +This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with an eslint plugin. It is similar to the problem `<script setup>` had before introducing the automatic `withAsyncContext()`. The same feature could be introduced but will also have a performance cost. ## Cache and loader reuse @@ -453,7 +436,7 @@ const { user, post } = toRefs(data.value) ## Usage outside of page components -Loaders can be **only exported from pages**. That's where the navigation guard picks them up, **but the page doesn't even need to use it**. It can be used in any component by importing the _returned composable_, even outside of the scope of the page components, by a parent. +Loaders can be **only exported from pages**. That's where the navigation guard picks them up, **but the page doesn't even need to 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, 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: @@ -528,7 +511,7 @@ export const useUserData = defineLoader( </script> <script setup> -// `user.value` can be `undefined` +// Differently from the example above, `user.value` can and will be initially `undefined` const { data: user, pending, error } = useUserData() // ^ Ref<User | undefined> </script> @@ -536,17 +519,18 @@ const { data: user, pending, error } = useUserData() 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. -TODO: it's possible to await all pending loaders with `await allPendingLoaders()`. 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? -TODO: transform a loader into a lazy version of it: `const useUserDataLazy = asLazyLoader(useUserData)` +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? +- 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) will cancel the navigation (same behavior as in a regular navigation guard) and get intercepted by [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) -- Redirection: TODO: use a helper? `return redirectTo(...)` / `navigateTo()` -- Cancelling the navigation: TODO: use a helper? `return abortNavigation()`. Other names? `abort(err?: any)` -- Other possibility: having one single `next()` function (or other name): rather not because can be called multiple times +- 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' @@ -562,20 +546,22 @@ export const useUserData = defineLoader( return new NavigationResult({ name: 'not-found', params: { pathMatch: } } ) } else { - throw error // same result as new NavigationResult(error) + throw error // aborts the vue router navigation } } } ) ``` -`new NavigationResult()` accepts anything that [can be returned in a navigation guard](https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards). +`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 `setupDataFetchingGuard()`: @@ -589,13 +575,7 @@ setupDataFetchingGuard(router, { }) ``` -This allows you to define any priority you want as well as select - -## Error handling - -Any error thrown within a loader will make the navigation fail. Differently from returning a value, a throw value will immediately reject the Data fetching Promise and `selectNavigationResult()` isn't called. - -Errors are handled by the [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) and can be intercepted by the `onError` hook. +`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. ## AbortSignal @@ -679,13 +659,7 @@ export const useBookCatalog = defineLoader(async () => { 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. -## HMR - -When changing the `<script>` the old cache is transferred and refreshed. Worst case, the page reloads for non lazy loaders. - -TODO: expand - -## Extending `defineLoader()` +<!-- ## Custom `defineLoader()` for libraries It's possible to extend the `defineLoader()` function to add new features such as a more complex cache system. TODO: @@ -694,7 +668,7 @@ ideas: - Export an interface that must be implemented by a composable so external libraries can implement custom strategies (e.g. vue-query) - allow global and local config (+ types) -TODO: investigate how integrating with vue-apollo would look like +TODO: investigate how integrating with vue-apollo would look like --> ## Global API @@ -708,27 +682,25 @@ TBD: is this worth it? Are any other functions needed? ## Limitations - Injections (`inject`/`provide`) cannot be used within a loader -- Watchers and other composables can be used but **will be created in a detached effectScope** and therefore never be released +- 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 -- Global composables like pinia might need special handling (e.g. calling `setActivePinia(pinia)` in a [`beforeLoad()` hook](#todo)) +- Global composables like pinia might need special handling (e.g. calling `setActivePinia(pinia)` in a [`beforeLoad()` hook](#setupdatafetchingguard)) # Drawbacks -This solution is not a silver bullet but I don't think one exists 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. - - Less intuitive than just awaiting something inside `setup()`: but it's still possible to await inside `setup()`, it's just not connected to the navigation - Requires an extra `<script>` tag but only for views. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function # Alternatives -- Allowing a `before` and `after` hook to allow changing data after each loader call. e.g. By default the data is preserved while a new one is being fetched - Allowing blocking data loaders to return objects of properties: ```ts export const useUserData = defineLoader(async (route) => { const user = await getUserById(route.params.id) - return user + // instead of return user + return { user } }) // instead of const { data: user } = useUserData() const { user } = useUserData() @@ -736,23 +708,6 @@ This solution is not a silver bullet but I don't think one exists because of the 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. -- Should we return directly the necessary data instead of wrapping it with an object and always name it `data`?: - - ```vue - <script lang="ts"> - import { getUserById } from '../api' - - export const useUserData = defineLoader(async (route) => { - const user = await getUserById(route.params.id) - return user - }) - </script> - - <script setup> - const { data: user, pending, error } = useUserData() - </script> - ``` - - Adding a new `<script loader>` similar to `<script setup>`: ```vue @@ -772,7 +727,7 @@ This solution is not a silver bullet but I don't think one exists because of the Is exposing every variable a good idea? -- Using Suspense to natively handle `await` within `setup()`. [See other RFC](#TODO). +- Using Suspense to natively handle `await` within `setup()`. This forces the router to concurrently render two pages to trigger the `async setup()` function. I don't think this is necessary as **it's already possible to use async setup** in page components for data fetching, it's just not integrated into the navigation. - Pass route properties instead of the whole `route` object: ```ts @@ -784,7 +739,7 @@ This solution is not a silver bullet but I don't think one exists because of the }) ``` - This has the problem of not being able to use the `route.name` to determine the correct typed params: + 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' @@ -805,6 +760,27 @@ Variables could be named differently and proposals are welcome: - `pending` (same as Nuxt) -> `isPending`, `isLoading` - Rename `defineLoader()` to `defineDataFetching()` (or others) +## Nested Invalidation syntax drawbacks + +- Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. + +- Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels considerably less ergonomic: + + ```ts + import { useUserData } from '~/pages/users/[id].vue' + + export const useUserFriends = defineLoader( + async (route, { loaders: [userData] }) => { + const friends = await getFriends(user.value.id) + return { ...userData.value, friends } + }, + { + // explicit dependencies + waitFor: [useUserData] + } + ) + ``` + ## Advanced `lazy` The `lazy` flag could be extended to also accept a number or a function. I think this is too much and should therefore not be included. @@ -820,7 +796,7 @@ import { getUserById } from '../api' export const useUserData = defineLoader( async (route) => { const user = await getUserById(route.params.id) - return { user } + return user }, // block the navigation for 1 second and then let the navigation go through { lazy: 1000 } @@ -828,9 +804,8 @@ export const useUserData = defineLoader( </script> <script setup> -// in this scenario, we no longer have a `user` property since `useUserData()` returns synchronously before the loader is resolved const { data, pending, error } = useUserData() -// ^ Ref<{ user: User } | undefined> +// ^ Ref<User | undefined> </script> ``` @@ -857,7 +832,7 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi # Unresolved questions -- Should there be a way to handle server only loaders? +- Should there by an `afterLoad()` hook, similar to `beforeLoad()`? - Is `useNuxtApp()` usable within loaders? - Is there anything needed besides the `route` inside loaders? - Add option for placeholder data? From 07140f7db0995ad7e60080d8fe579c4ecedda927 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 24 Aug 2022 15:06:23 +0200 Subject: [PATCH 17/25] more --- active-rfcs/0000-router-onBeforeNavigate.md | 2 ++ active-rfcs/0000-router-use-loader.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/active-rfcs/0000-router-onBeforeNavigate.md b/active-rfcs/0000-router-onBeforeNavigate.md index 4071b04d..9cb23faa 100644 --- a/active-rfcs/0000-router-onBeforeNavigate.md +++ b/active-rfcs/0000-router-onBeforeNavigate.md @@ -1,3 +1,5 @@ +> This RFC is dropped in favor of the [data loaders RFC](./0000-router-use-loader.md). + - Start Date: 2022-02-11 - Target Major Version: Vue Router >=4 - Reference Issues: diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index a0db8679..b8f9eb77 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -16,7 +16,7 @@ List of things that haven't been added to the document yet: 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: -- Automatically integrate fetching to the navigation cycle (or not by making it non-blocking) +- Automatically integrate fetching to the navigation cycle (or not by making it _lazy/non-blocking_) - Automatically rerun when used params/query params/hash changes (avoid unnecessary fetches) - Basic client-side caching with time-based expiration to only fetch once per navigation while using it anywhere - Provide control over loading/error states @@ -689,7 +689,7 @@ TBD: is this worth it? Are any other functions needed? # Drawbacks -- Less intuitive than just awaiting something inside `setup()`: but it's still possible to await inside `setup()`, it's just not connected to the navigation +- Less intuitive than just awaiting something inside `setup()` with `<Suspense>` but does not suffer from cascade fetching (nested calls wait for parents in some scenarios). Note it's still possible to await inside `setup()`, it's just not connected to the navigation. - Requires an extra `<script>` tag but only for views. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function # Alternatives From 8978c42b1190c7477d633539e547a0954908dfd1 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Wed, 24 Aug 2022 15:10:40 +0200 Subject: [PATCH 18/25] easier to have one file only --- active-rfcs/0000-router-onBeforeNavigate.md | 307 -------------------- 1 file changed, 307 deletions(-) delete mode 100644 active-rfcs/0000-router-onBeforeNavigate.md diff --git a/active-rfcs/0000-router-onBeforeNavigate.md b/active-rfcs/0000-router-onBeforeNavigate.md deleted file mode 100644 index 9cb23faa..00000000 --- a/active-rfcs/0000-router-onBeforeNavigate.md +++ /dev/null @@ -1,307 +0,0 @@ -> This RFC is dropped in favor of the [data loaders RFC](./0000-router-use-loader.md). - -- Start Date: 2022-02-11 -- Target Major Version: Vue Router >=4 -- Reference Issues: -- Implementation PR: - -# Summary - -Better integrating Data Fetching logic with the router navigation: - -- Automatically include `await` calls within an ongoing navigation for components that are mounted as a result of the navigation -- Navigation guards declared **within** `setup()` -- Loading and Error Boundaries thanks to Suspense -- Single Fetching on Hydration (avoid fetching on server and client when doing SSR) - -# Basic example - -- Fetching data once - -Data fetching when entering the router. This is simpler than the `onBeforeNavigate` example and is what most users would expect to work. It also integrates with the navigation: - -```vue -<script setup lang="ts"> -import { useUserStore } from '~/stores/user' // store with pinia - -const userStore = useUserStore() -await userStore.fetchUser(to.params.id) -</script> - -<template> - <h2>{{ userStore.userInfo.name }}</h2> - <ul> - <li>Email: {{ userStore.userInfo.email }}</li> - ... - </ul> -</template> -``` - -- Fetching data with each navigation - -Data fetching integrates with the router navigation within any component. - -```vue -<script setup lang="ts"> -import { onBeforeNavigate } from 'vue-router' -import { useUserStore } from '~/stores/user' // store with pinia - -const userStore = useUserStore() - -// Triggers on entering or updating the route: -// / -> /users/2 ✅ -// /users/2 -> /users/3 ✅ -// /users/2 -> / ❌ -onBeforeNavigate(async (to, from) => { - // could also be return userStore.fetchUser(...) - await userStore.fetchUser(to.params.id) -}) -</script> - -<template> - <h2>{{ userStore.userInfo.name }}</h2> - <ul> - <li>Email: {{ userStore.userInfo.email }}</li> - ... - </ul> -</template> -``` - -# Motivation - -Today's data fetching with Vue Router is simple when the data fetching is simple enough but it gets complicated to implement in advanced use cases that involve SSR. It also doesn't work with `async setup()` + Suspense. - -# Detailed design - -## Fetching data once - -You can do initial fetching (when it only needs to happen once) **without using anything specific to Vue Router**: - -```vue -<script setup lang="ts"> -import { getUserList } from '~/apis/user' - -const userList = await getUserList() -// or if you need a reactive version -const userList = reactive(await getUserList()) -</script> -``` - -- The router navigation won't be considered settled until all `await` (or returned promises inside `setup()`) are resolved -- This creates a redundant fetch with SSR because `userList` is never serialized into the page for hydration. In other words: **don't do this if you are doing SSR**. -- Simplest data fetching pattern, some people are probably already using this pattern. -- Rejected promises are caught by `router.onError()` and cancel the navigation. Note that differently from `onErrorCaptured()`, it's not possible to return `false` to stop propagating the error. - -## Fetching with each navigation - -Use this when the fetching depends on the route (params, query, ...) like a route `/users/:id` that allows navigating through users. All the rules above apply: - -```vue -<script setup lang="ts"> -import { onBeforeNavigate } from 'vue-router' -import { getUser } from '~/apis/user' - -const user = ref<User>() -onBeforeNavigate(async (to) => { - user.value = await getUser(to.params.id) -}) -</script> -``` - -- Any component that is rendered by RouterView or one of its children can call `onBeforeNavigate()` -- `onBeforeNavigate()` triggers on entering and, updating. It **doesn't trigger when leaving**. This is because it's mostly used to do data fetching and you don't want to fetch when leaving. You can still use `onBeforeRouteLeave()`. -- Can be called multiple times -- It returns a promise that resolves when the fetching is done. This allows to await it to use any value that is updated within the navigation guard: - - ```ts - const user = ref<User>() - await onBeforeNavigate(async (to) => { - user.value = await getUser(to.params.id) - }) - user.value // populated because we awaited - ``` - -- Awaiting or not `onBeforeNavigate()` doesn't change the fact that the navigation is settled only when all `await` are resolved. - -## SSR support - -To properly handle SSR we need to: - -- Serialize the fetched data to hydrate: **which is not handled by the router** -- Avoid the double fetching issue by only fetching on the server - -Serializing the state is out of scope for the router. Nuxt defines a `useState()` composable. A pinia store can also be used to store the data: - -```vue -<script setup lang="ts"> -import { onBeforeNavigate } from 'vue-router' -import { storeToRefs } from 'pinia' -import { useUserStore } from '~/stores/user' // store with pinia - -const userStore = useUserStore() -const { userInfo } = storeToRefs(userStore) - -// The navigation guard will be skipped during the first rendering on client side because everything was done on the server -// This will avoid the double fetching issue as well and ensure the navigation is consistent -await onBeforeNavigate(async (to, from) => { - // we could return a promise too - await userStore.fetchUser(to.params.id) - - // here we could also return false, "/login", throw new Error() - // like a regular navigation guard -}) -</script> - -<template> - <h2>{{ userInfo.name }}</h2> - <ul> - <li>Email: {{ userInfo.email }}</li> - ... - </ul> -</template> -``` - -To avoid the double fetching issue, we can detect the hydration during the initial navigation and skip navigation guards altogether as they were already executed on the server. **This is however a breaking change**, so it would require a new option to `createRouter()` that disables the feature by default: - -```ts -createRouter({ - // ... - skipInitialNavigationGuards: true -}) -``` - -Another solution is to only skip `onBeforeNavigate()` guards during the initial navigation on the client if it was hydrated. This is not a breaking change since the API is new but it does make things inconsistent. - -### Using a new custom `useDataFetching()` - -Maybe we could expose a utility to handle SSR **without a store** but I think [serialize-revive](https://github.com/kiaking/vue-serialize-revive) should come first. Maybe internally they could use some kind of `onSerialization(key, fn)`/`onHydration(key, fn)`. - -```vue -<script setup lang="ts"> -import { onBeforeNavigate, useDataFetching } from 'vue-router' -import { getUserInfo } from '~/apis/user' - -// only necessary with SSR without Pinia (or any other store) -const userInfo = useDataFetching(getUserInfo) -// this await allows for Suspense to wait for the _entering_ navigation -await onBeforeNavigate(async (to, from) => { - // await (we could return a promise too) - await userInfo.fetch(to.params.id) -}) -</script> - -<template> - <h2>{{ userInfo.name }}</h2> - <ul> - <li>Email: {{ userInfo.email }}</li> - ... - </ul> -</template> -``` - -`useDataFetching()` is about being able to hydrate the information but `onBeforeNavigate()` only needs to return a promise: - -## Canceled Navigations - -A canceled navigation is a navigation that is canceled by the code (e.g. unauthorized access to an admin page) and results in the route **not changing**. It triggers `router.afterEach()`. Note this doesn't include redirecting (e.g. `return "/login"` but not `redirect: '/login'` (which is a _"rewrite"_ of the ongoing navigation)) as they trigger a whole new navigation that gets _"appended"_ to the ongoing navigation. - -## Failed Navigations - -An failed navigation is different from a canceled navigation because it comes from an unexpected uncaught thrown error. It also triggers `router.onError()`. - -# Drawbacks - -## Larger CPU/Memory usage - -In order to execute any `await` statement, we have to mount the pending routes within a Suspense boundary while still displaying the current view which means two entire views are rendered during the navigation. This can be slow for big applications but no real testing has been done to prove this could have an impact on UX. - -## Using Suspense - -Currently Suspense allows displaying a `fallback` content after a certain timeout. This is useful when displaying a loading screen but there are other ways of telling the user we are waiting for asynchronous operations in the back like a progress bar or a global loader. -The issue with the `fallback` slot is it is **only controlled by `await` inside of mounting component**. Meaning there is no programmatic API to _reenter_ the loading state and display the `fallback` slot in this scenario: - -Given a Page Component for `/users/:id`: - -```vue -<script setup lang="ts"> -import { onBeforeNavigate } from 'vue-router' -import { useUserStore } from '~/stores/user' // store with pinia - -const userStore = useUserStore() - -await onBeforeNavigate(async (to, from) => { - await userStore.fetchUser(to.params.id) -}) -</script> - -<template> - User: {{ userStore.user.name }} - <ul> - <li v-for="user in userStore.user.friends"> - <RouterLink :to="`/users/${user.id}`">{{ user.name }}</RouterLink> - </li> - </ul> -</template> -``` - -And an `App.vue` as follows - -```vue -<template> - <!-- SuspendedRouterView internally uses Suspense around the rendered page and inherits all of its props and slots --> - <SuspendedRouterView :timeout="800"> - <template #fallback> Loading... </template> - </SuspendedRouterView> -</template> -``` - -- Going from any other page to `/users/1` displays the "Loading..." message if the request for the user information takes more than 800ms -- Clicking on any of the users and effectively navigating to `/users/2` - -# Alternatives - -What other designs have been considered? What is the impact of not doing this? - -- Not using Suspense: Implementing vue router's own fallback/error mechanism: not sure if doable because we need to try mounting the components to "collect" their navigation guards. - -## To Suspense `fallback` not displaying on the same route navigations - -We could retrieve the status of the closest `<Suspense>` boundary: - -```js -const { state } = useSuspense() -state // TBD: Ref<'fallback' | 'settled' | 'error'> -``` - -There could maybe also be a way to programmatically change the state of the closest Suspense boundary: - -```js -const { appendJob } = useSuspense() -// add a promise, enters the loading state if Suspense is not loading -appendJob( - (async () => { - // some async operation - })() -) -``` - -## Suspense Limitations - -- Any provided/computed value is updated even in the non active branches of Suspense. Would there be a way to ensure `computed` do not update (BTW keep alive also do this) or to freeze provided reactive values? - -# Adoption strategy - -If we implement this proposal, how will existing Vue developers adopt it? Is -this a breaking change? Can we write a codemod? Can we provide a runtime adapter library for the original API it replaces? How will this affect other projects in the Vue ecosystem? - -# Unresolved questions - -- What should happen when an error is thrown (See https://github.com/vuejs/core/issues/1347) - - Currently the `fallback` slot stays visible - - Should the router rollback the previous route location? -- `useDataFetching()`: - - - Do we need anything else apart from `data`, and `fetch()`? - - loading/error state: even though it can be handled by Suspense `fallback` slot, we could still make them appear here for users not using a `fallback` slot - -- Maybe we should completely skip the navigation guards when hydrating From fb2016b343954c39f219db56e4948c8dfb933e5c Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 2 Sep 2022 14:46:47 +0200 Subject: [PATCH 19/25] add ssr lazy --- active-rfcs/0000-router-use-loader.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index b8f9eb77..99c06213 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -1,7 +1,7 @@ - Start Date: 2022-07-14 - Target Major Version: Vue 3, Vue Router 4 - Reference Issues: -- Implementation PR: +- Implementation PR: - https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching # Todo List @@ -271,7 +271,7 @@ By default, loaders are executed as soon as possible, in parallel. This scenario Sometimes, requests depend on other fetched data (e.g. fetching additional user information). For these scenarios, we can simply import the other loaders and use them **within a different loader**: -Call **and `await`** the loader inside the one that needs it, it will only be fetched once no matter how many times it is called used: +Call **and `await`** the loader inside the one that needs it, it will only be fetched once no matter how many times it is called: ```ts // import the loader for user information @@ -290,7 +290,7 @@ export const useUserCommonFriends = defineLoader(async (route) => { ### Nested invalidation -Since `useUserData()` loader calls `useUserCommonFriends()`, if `useUserData()`'s cache expires or gets manually invalidated, it will also automatically invalidate `useUserCommonFriends()`. +Since `useUserCommonFriends()` loader calls `useUserData()`, if `useUserData()`'s cache expires or gets manually invalidated, it will also automatically invalidate `useUserCommonFriends()`. Note that two loaders cannot use each other as that would create a _dead lock_. @@ -519,9 +519,22 @@ const { data: user, pending, error } = useUserData() 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? +- [~~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 From da7eaec73b29369db6d523a1122ff2f18e65201b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 2 Sep 2022 14:48:41 +0200 Subject: [PATCH 20/25] setupLoaderGuard naming --- active-rfcs/0000-router-use-loader.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 99c06213..ecdf2f7e 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -119,13 +119,13 @@ Loaders also have the advantage of behaving as singleton requests. This means th To setup the loaders, we first need to setup the navigation guards: ```ts -import { setupDataFetchingGuard, createRouter } from 'vue-router' +import { setupLoaderGuard, createRouter } from 'vue-router' const router = createRouter({ // ... }) -setupDataFetchingGuard(router) +setupLoaderGuard(router) ``` Then, for each page exporting a loader, we need to add a meta property to the route: @@ -225,18 +225,18 @@ const { } = useUserData() ``` -## `setupDataFetchingGuard()` +## `setupLoaderGuard()` -`setupDataFetchingGuard()` setups a navigation guard that handles all the loaders. In SPA, its usage is very simple: +`setupLoaderGuard()` setups a navigation guard that handles all the loaders. In SPA, its usage is very simple: ```ts -setupDataFetchingGuard(router) +setupLoaderGuard(router) ``` You can also pass a second argument for some global options. In [SSR](#ssr), you can also retrieve the fetchedData as its returned value: ```ts -const fetchedData = setupDataFetchingGuard( +const fetchedData = setupLoaderGuard( router, // the router instance for the app { // hook triggered before each loader is ran @@ -577,10 +577,10 @@ The only difference between throwing an error and returning a `NavigationResult` ### 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 `setupDataFetchingGuard()`: +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 -setupDataFetchingGuard(router, { +setupLoaderGuard(router, { selectNavigationResult(results) { // results is an array of the unwrapped results passed to `new NavigationResult()` return results.find((result) => result.name === 'not-found') @@ -608,7 +608,7 @@ This aligns with the future [Navigation API](https://github.com/WICG/navigation- To support SSR we need to do two things: - Pass a `key` to each loader so that it can be serialized into an object later. Would an array work? I don't think the order of execution is guaranteed. -- On the client side, pass the initial state to `setupDataFetchingGuard()`. The initial state is used once and discarded afterwards. +- On the client side, pass the initial state to `setupLoaderGuard()`. The initial state is used once and discarded afterwards. ```ts export const useBookCollection = defineLoader( @@ -620,11 +620,11 @@ export const useBookCollection = defineLoader( ) ``` -The configuration of `setupDataFetchingGuard()` depends on the SSR configuration, here is an example with vite-ssg: +The configuration of `setupLoaderGuard()` depends on the SSR configuration, here is an example with vite-ssg: ```ts import { ViteSSG } from 'vite-ssg' -import { setupDataFetchingGuard } from 'vue-router' +import { setupLoaderGuard } from 'vue-router' import App from './App.vue' import { routes } from './routes' @@ -633,7 +633,7 @@ export const createApp = ViteSSG( { routes }, async ({ router, isClient, initialState }) => { // fetchedData will be populated during navigation - const fetchedData = setupDataFetchingGuard(router, { + const fetchedData = setupLoaderGuard(router, { initialData: isClient ? // on the client we pass the initial state initialState.vueRouter @@ -649,7 +649,7 @@ export const createApp = ViteSSG( ) ``` -Note that `setupDataFetchingGuard()` **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. +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 From e5ce9ccc27df4a7cb7ebf81767961db3fcd0c984 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 23 Sep 2022 14:56:10 +0200 Subject: [PATCH 21/25] feedback for suspense From https://github.com/vuejs/rfcs/discussions/460#discussioncomment-3536709 --- active-rfcs/0000-router-use-loader.md | 67 +++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index ecdf2f7e..2fd1846c 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -321,7 +321,7 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` -In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called nor try optimizing them because they are only called once and share the data. +In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called nor try optimizing them because **they are only called once and share the data**. **Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different regular `await` in between, it has to be wrapped with a `withDataContext()`: @@ -493,7 +493,7 @@ const { data: user, pending, error } = useUserData() 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 +## 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**. @@ -702,11 +702,71 @@ TBD: is this worth it? Are any other functions needed? # Drawbacks -- Less intuitive than just awaiting something inside `setup()` with `<Suspense>` but does not suffer from cascade fetching (nested calls wait for parents in some scenarios). Note it's still possible to await inside `setup()`, it's just not connected to the navigation. +- At first, it looks less intuitive than just awaiting something inside `setup()` with `<Suspense>` [but it doesn't have its limitations](#limitations) - Requires an extra `<script>` tag but only for views. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function # Alternatives +## Suspense + +Using Suspense is probably the first alternative that comes to mind and it has been considered as a solution for data fetching by implementing proofs of concepts. It however suffer from major drawbacks that are tied to its current design and is not a viable solution for data fetching. + +One could imagine being able to write something like: + +```vue +<!-- src/pages/users.vue = /users --> +<!-- Displays a list of all users --> +<script setup> +const userList = shallowRef(await fetchUserList()) + +// manually expose a refresh function to be called whenever needed +function refresh() { + userList.value = await fetchUserList() +} +</script> +``` + +Or when params are involved in the data fetching: + +```vue +<!-- src/pages/users.[id].vue = /users/:id --> +<!-- Displays a list of all users --> +<script setup> +const route = useRoute() +const user = shallowRef(await fetchUserData(route.params.id)) + +// manually expose a refresh function to be called whenever needed +function refresh() { + user.value = await fetchUserData(route.params.id) +} + +// hook into navigation instead of a watcher because we want to block the navigation +onBeforeRouteUpdate(async (to) => { + // note how we need to use `to` and not `route` here + user.value = await fetchUserData(to.params.id) +}) +</script> +``` + +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 in some navigations a **sequential data fetching**: it's not possible to ensure an **optimal parallel fetching** in all cases +- Manual data refreshing is necessary **unless you add a `key` attribute** to the `<RouterView>` 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 `<transition>` but less flexible than the proposed solution which can also use 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 gather the same data fetching 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** 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. + +## Other alternatives + - Allowing blocking data loaders to return objects of properties: ```ts @@ -740,7 +800,6 @@ TBD: is this worth it? Are any other functions needed? Is exposing every variable a good idea? -- Using Suspense to natively handle `await` within `setup()`. This forces the router to concurrently render two pages to trigger the `async setup()` function. I don't think this is necessary as **it's already possible to use async setup** in page components for data fetching, it's just not integrated into the navigation. - Pass route properties instead of the whole `route` object: ```ts From 50dc02903b8a8ba403ea89ff0d36e6bb1953b1c1 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 23 Sep 2022 15:09:58 +0200 Subject: [PATCH 22/25] loaders can be declared anywhere but have to be present in page exports --- active-rfcs/0000-router-use-loader.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 2fd1846c..b68f8a1f 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -84,7 +84,9 @@ There are currently too many ways of handling data fetching with vue-router and - `beforeRouteEnter()`: non typed and non-ergonomic API with `next()`, requires a data store (pinia, vuex, apollo, etc) - using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) -The goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer. +People are left with a low level API (navigation guards) to handle data fetching themselves. This is often a difficult problem to solve because it requires an extensive knowledge of the Router concepts and in reality, very few people know them. + +Thus, the goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer. There are features that are out of scope for this proposal but should be implementable in user-land thanks to an _extendable API_: @@ -108,7 +110,7 @@ Ideally, this should be used alongside [unplugin-vue-router](https://github.com/ - Adds a navigation guard that resolve loaders (should be moved to vue-router later) - Implements a `defineLoader()` composable (should be moved to vue-router later) -`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when calling the composable. +`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders can be declared anywhere but **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when calling the composable. Limiting the loader access to only the target route, ensures that the data can be fetched when the user refresh the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params to create sharable URLs. Within loaders there is no current instance and no access to `inject`/`provide` APIs. @@ -436,7 +438,7 @@ const { user, post } = toRefs(data.value) ## Usage outside of page components -Loaders can be **only exported from pages**. That's where the navigation guard picks them up, **but the page doesn't even need to 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, by a parent. +Loaders are picked up when exported from pages by a navigation guard, **but the page doesn't even need to 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: From ddcbb064a4c716431cad5197105b49592206b211 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 28 Oct 2022 18:17:22 +0200 Subject: [PATCH 23/25] minor updates --- active-rfcs/0000-router-use-loader.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index b68f8a1f..36b87274 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -1,7 +1,7 @@ - Start Date: 2022-07-14 - Target Major Version: Vue 3, Vue Router 4 - Reference Issues: -- Implementation PR: - https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching +- Implementation PR: - <https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching> # Todo List @@ -60,10 +60,12 @@ const { data: user, pending, error, refresh } = useUserData() - `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. -- **Only page components** can export loaders but **loaders can be defined anywhere**. +- Define and use Data Loaders **anywhere**. Export them **in page components** to attach them to pages. - Loaders smartly know which params/query params/hash they depend on to force a refresh when navigating: - - Going from `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was - - Going from `/users?name=fab` to `/users?name=fab#filters` checks if the current client side cache is recent enough to not fetch again + - Going from `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was because `useUserData` depends on `route.params.id` + - Going from `/users/2?name=fab` to `/users/2?name=fab#filters` will try to avoid fetching again as the `route.params.id` didn't change: it will check if the current client side cache expired and if it did, it will fetch again + +In each of these cases, **the data loaders blocks the navigation**, meaning it integrates transparently with SSR and any errors can be handled at the router level. On top of that, data loaders are deduped, which means that no mather how many times you use the same loader in different places, **it will still load the data just once**. The simplest of loaders can be defined in just one line and types will be automatically inferred: @@ -912,3 +914,9 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi - Add option for placeholder data? - What other operations might be necessary for users? - Is there a way to efficiently parse the exported properties in pages to filter out pages that have named exports but no loaders? + +<!-- + +TODO: we could attach an effect scope it each loader, allowing craeting reactive variables that are automatically cleaned up when the loader is no longer used by collecting whenever the `useLoader()` fn is called and removing them when the component is unmounted, if the loader is not used anymore, remove the effect scope as well. This requires a way to create the variables so the user can pass a custom composable. + + --> From 9fcbb6e3d820438967c1284ae0f3ca55608d4a30 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 4 Nov 2022 13:51:07 -0400 Subject: [PATCH 24/25] explain promise return --- active-rfcs/0000-router-use-loader.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 36b87274..41e157d7 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -292,6 +292,8 @@ export const useUserCommonFriends = defineLoader(async (route) => { }) ``` +You will notice here that we have two different usages for `useUserData()`, one that returns all the necessary information we need _synchronously_, and a second version that returns the same data but _asynchronously_, in other words, a Promise. This allows to await for the data to be loaded so it can be used directly within the loader. Note you could `await` within `setup()` but **that would be unnecessary** as the loader is already awaited at the navigation layer. + ### Nested invalidation Since `useUserCommonFriends()` loader calls `useUserData()`, if `useUserData()`'s cache expires or gets manually invalidated, it will also automatically invalidate `useUserCommonFriends()`. @@ -917,6 +919,6 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi <!-- -TODO: we could attach an effect scope it each loader, allowing craeting reactive variables that are automatically cleaned up when the loader is no longer used by collecting whenever the `useLoader()` fn is called and removing them when the component is unmounted, if the loader is not used anymore, remove the effect scope as well. This requires a way to create the variables so the user can pass a custom composable. +TODO: we could attach an effect scope it each loader, allowing creating reactive variables that are automatically cleaned up when the loader is no longer used by collecting whenever the `useLoader()` fn is called and removing them when the component is unmounted, if the loader is not used anymore, remove the effect scope as well. This requires a way to create the variables so the user can pass a custom composable. --> From aa6b17033170fb828bc822f1f4de6b156b256a9d Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Tue, 9 Jan 2024 10:17:00 +0100 Subject: [PATCH 25/25] more updates --- active-rfcs/0000-router-use-loader.md | 379 +++++++++++++++----------- 1 file changed, 220 insertions(+), 159 deletions(-) diff --git a/active-rfcs/0000-router-use-loader.md b/active-rfcs/0000-router-use-loader.md index 41e157d7..875c14c1 100644 --- a/active-rfcs/0000-router-use-loader.md +++ b/active-rfcs/0000-router-use-loader.md @@ -13,22 +13,31 @@ List of things that haven't been added to the document yet: # 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. +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: -- Automatically integrate fetching to the navigation cycle (or not by making it _lazy/non-blocking_) -- Automatically rerun when used params/query params/hash changes (avoid unnecessary fetches) -- Basic client-side caching with time-based expiration to only fetch once per navigation while using it anywhere +- 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 -- Allow parallel or sequential data fetching (loaders that use each other) +- 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 + +<!-- Extra Goals: + +- Automatically rerun when used params/query params/hash changes (avoid unnecessary fetches) +- Basic client-side caching with time-based expiration to only fetch once per navigation while using it anywhere --> -This proposal concerns the Vue Router 4 but some examples concern [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) usage for improved DX. Especially the typed routes usage. Note this new API doesn't require the mentioned plugin but it **greatly improves the DX**. +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 can define any amount of _loaders_ by **exporting them** in **page components** (components associated to a route). They return a **composable that can be used in any component** (not only pages). +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 is **exported** from a non-setup `<script>` in a page component: +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 `<script>` in a page component: ```vue <script lang="ts"> @@ -57,24 +66,55 @@ const { data: user, pending, error, refresh } = useUserData() </script> ``` +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. +- `useUserData()` can be used in **any component**, not only in the one that defines it. We import the function and call it within `<script setup>` like other composables - Define and use Data Loaders **anywhere**. Export them **in page components** to attach them to pages. + +<!-- Advanced use case (cached): + - Loaders smartly know which params/query params/hash they depend on to force a refresh when navigating: - Going from `/users/2` to `/users/3` will refresh the data no matter how recent the other fetch was because `useUserData` depends on `route.params.id` - - Going from `/users/2?name=fab` to `/users/2?name=fab#filters` will try to avoid fetching again as the `route.params.id` didn't change: it will check if the current client side cache expired and if it did, it will fetch again + - Going from `/users/2?name=fab` to `/users/2?name=fab#filters` will try to avoid fetching again as the `route.params.id` didn't change: it will check if the current client side cache expired and if it did, it will fetch again --> -In each of these cases, **the data loaders blocks the navigation**, meaning it integrates transparently with SSR and any errors can be handled at the router level. On top of that, data loaders are deduped, which means that no mather how many times you use the same loader in different places, **it will still load the data just once**. +In each of these cases, **the data loaders block the navigation**, meaning it integrates transparently with SSR and any errors can be handled at the router level. On top of that, data loaders are deduped, which means that no mather how many times you use the same loader in different places, **it will still load the data just once**. -The simplest of loaders can be defined in just one line and types will be automatically inferred: +The simplest of data loaders can be defined in just one line and types will be automatically inferred: ```ts -export const useBookCollection = defineLoader(fetchBookCollection) // function fetchBookCollection(): Promise<Books[]> +export const useBookCollection = defineLoader(fetchBookCollection) ``` -Note that this syntax will intentionally be avoided in the RFC. Instead, we will often use slightly longer examples to make things easier to follow by anyone. +Note that this syntax will intentionally be avoided in the RFC. Instead, we will often use slightly longer examples to make things easier to follow. # Motivation @@ -84,11 +124,12 @@ There are currently too many ways of handling data fetching with vue-router and - using `meta`: complex to setup even for simple cases, too low level for such a common case - using `onBeforeRouteUpdate()`: missing data when entering tha page - `beforeRouteEnter()`: non typed and non-ergonomic API with `next()`, requires a data store (pinia, vuex, apollo, etc) - - using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) +- using a watcher on `route.params...`: component renders without the data (doesn't work with SSR) +- TODO: using suspense? People are left with a low level API (navigation guards) to handle data fetching themselves. This is often a difficult problem to solve because it requires an extensive knowledge of the Router concepts and in reality, very few people know them. -Thus, the goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and allow extensibility. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer. +Thus, the goal of this proposal is to provide a simple yet configurable way of defining data loading in your application that is easy to understand and use. It should also be compatible with SSR and not limited to simple _fetch calls_. It should be adoptable by frameworks like Nuxt.js to provide an augmented data fetching layer that integrates well with Vue.js Concepts and the future of Web APIs. There are features that are out of scope for this proposal but should be implementable in user-land thanks to an _extendable API_: @@ -98,29 +139,33 @@ There are features that are out of scope for this proposal but should be impleme This RFC also aims to integrate data fetching within navigations while still not forcing you to block navigation with data fetching. This pattern is useful for multiple reasons: -- Ensure data is present before mounting the component +- Ensure data is present before mounting the component (blocks navigation) +- Flexibility to not wait for non critical data with lazy data loaders - Enables the UX pattern of letting the browser handle loading state (aligns better with [future browser APIs](https://github.com/WICG/navigation-api)) -- Makes scrolling work out of the box when navigating between pages -- Ensure fetching happens only once +- Makes scrolling work out of the box when navigating between pages (when data is blocking) +- Ensure one single request per loader and navigation - Extremely lightweight compared to more complex fetching solutions like vue-query/tastack-query, apollo/graphql, etc # Detailed design -Ideally, this should be used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide a better DX by automatically adding the proper meta fields **but it's not necessary**. At the moment, this API is implemented in that plugin as an experiment, check the [Experimental Data fetching](https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching). By default, it: +> [!NOTE] +> In the examples, Data Loaders are used alongside [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to provide typed routes **but it can be used outside**. At the moment, Data Loaders are implemented in that plugin as an experiment, check the [Experimental Data fetching](https://github.com/posva/unplugin-vue-router/tree/main/src/data-fetching). -- Checks named exports in page components to set a meta property in the generated routes -- Adds a navigation guard that resolve loaders (should be moved to vue-router later) -- Implements a `defineLoader()` composable (should be moved to vue-router later) +## Basic Data Loader -`defineLoader()` takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders can be declared anywhere but **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as an argument and must return a Promise of an object of properties that will then be directly accessible **as refs** when calling the composable. +In its simplest form, the basic implementation for `defineLoader()` in [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) takes a function that returns a promise (of data) and returns a composable that **can be used in any component**, not only in the one that defines it. We call these _loaders_. Loaders can be declared anywhere but **must be exported by page components** in order for them to get picked up and executed during the navigation. They receive the target `route` as well as other properties, as an argument and must return a Promise. The resolved value will then be directly accessible as a ref when calling the composable. -Limiting the loader access to only the target route, ensures that the data can be fetched when the user refresh the page. In enforces a good practice of correctly storing the necessary information in the route as params or query params to create sharable URLs. Within loaders there is no current instance and no access to `inject`/`provide` APIs. +Limiting the loader access to only the target route, ensures that the data can be fetched **when the user refreshes the page**. In enforces a good practice of correctly storing the necessary information in the route as params or query params to create sharable URLs. Within loaders there is no access to the current component or page instance, but it's possible to access global injections created with `app.provide()`. This includes stores created with [Pinia](https://pinia.vuejs.org). -Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many page components export the loader or how many regular components use it. It also means that all the refs (`data`, `pending`, etc) are created only once, in a detached effect scope. +Loaders also have the advantage of behaving as singleton requests. This means that they are only fetched once per navigation no matter how many times the loader is attached or how many regular components use it. It also means that all the refs (`data`, `pending`, etc) are created only once and shared by all components, reducing memory usage. ## Setup -To setup the loaders, we first need to setup the navigation guards: +> [!TIP] +> This is done automatically in [unplugin-vue-router](https://github.com/posva/unplugin-vue-router). +> The examples below import from `vue-router` but it's not sure if this API will be exported from there in the future. + +To enable data loaders, we need to setup the navigation guard that handles it: ```ts import { setupLoaderGuard, createRouter } from 'vue-router' @@ -129,69 +174,56 @@ const router = createRouter({ // ... }) -setupLoaderGuard(router) +export function setupLoaderGuard({ + router, + app // vue createApp() +}) ``` -Then, for each page exporting a loader, we need to add a meta property to the route: +When doing Lazy loading, the loader will be automatically picked up by the navigation guard: ```ts -import { LoaderSymbol } from 'vue-router' - +// lazy loaded route const routes = [ { path: '/users/:id', - component: () => import('@/pages/UserDetails.vue'), - meta: { - // 👇 v array of all the necessary loaders - [LoaderSymbol]: [() => import('@/pages/UserDetails.vue')] - }, - }, - // Named views must include all page component lazy imports - { - path: '/users/:id', - components: { - default () => import('@/pages/UserDetails.vue'), - aux () => import('@/pages/UserDetailsAux.vue'), - }, - meta: { - [LoaderSymbol]: [() => import('@/pages/UserDetails.vue'), () => import('@/pages/UserDetailsAux.vue')], - }, - }, - // Nested routes follow the same pattern, declare an array of lazy imports relevant to each routing level + // automatically picks up any loader exported + component: () => import('@/pages/UserDetails.vue') + } +] +``` + +If the route isn't lazy loaded, the loader can be directly defined in an array of loaders on `meta.loaders`: + +```ts +import { useUserDetails } from '@/loaders/users' +import UserDetails from '@/pages/UserDetails.vue' + +const routes = [ { path: '/users/:id', - component: () => import('@/pages/UserDetails.vue'), + component: UserDetails, meta: { - [LoaderSymbol]: [() => import('@/pages/UserDetails.vue')], - }, - children: [ - { - path: 'edit', - component: () => import('@/pages/UserEdit.vue'), - meta: { - [LoaderSymbol]: [() => import('@/pages/UserEdit.vue')], - }, - } - ] - }, + // must be added to the meta property + loaders: [useUserDetails] + } + } ] ``` -This is **pretty verbose** and that's why it is recommended to use [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) to make this **completely automatic**: the plugin generates the routes with the symbols and loaders. It will also setup the navigation guard when creating the router instance. -When using the plugin, any page component with **named exports will be marked** with a symbol to pick up any possible loader in a navigation guard. The navigation guard checks every named export for loaders and _load_ them. - When using vue router named views, each named view can have their own loaders but note any navigation to the route will trigger **all loaders from all page components**. -Note: with unplugin-vue-router, a named view can be declared by appending `@name` at the end of the file name: - -```text -src/pages/ -└── users/ - ├── index.vue - └── index@aux.vue -``` - -This creates a `components: { default: ..., aux: ... }` entry in the route config. +> [!TIP] +> Note: with unplugin-vue-router, a named view can be declared by appending `@name` at the end of the file name: +> +> ```text +> src/pages/ +> └── users/ +> ├── index.vue +> └── index@aux.vue +> ``` +> +> This creates a `components: { default: ..., aux: ... }` entry in the route config. ## `defineLoader()` @@ -204,16 +236,15 @@ const { pending, // Ref<boolean> error, // Ref<any> refresh, // () => Promise<void> - invalidate, // () => void - pendingLoad, // () => Promise<void> | null | undefined } = useLoader() ``` -- `refresh()` calls `invalidate()` and then invokes the loader (an internal version that sets the `pending` and other flags) -- `invalidate()` updates the cache entry time in order to force a reload next time it is triggered -- `pendingLoad()` returns a promise that resolves when the loader is done or `null` if no load is currently pending +- `data` contains the resolved value returned by the loader +- `pending` is `true` while a request is pending and becomes `false` once the request is settled +- `error` becomes `null` each time a request starts and is filled with the error thrown by the loader +- `refresh()` invokes the loader (an internal version that sets the `pending` and other flags) -Blocking loaders (the default) also return a ref of the returned data by the loader. Usually, it makes sense to rename this `data` property: +In practice, rename `data` (or others) to something more meaningful: ```ts import { getUserById } from '@/api/users' @@ -229,43 +260,45 @@ const { } = useUserData() ``` +`defineLoader()` can be passed some options to customize its behavior + +### `lazy` + +### `commit` + ## `setupLoaderGuard()` -`setupLoaderGuard()` setups a navigation guard that handles all the loaders. In SPA, its usage is very simple: +`setupLoaderGuard()` setups a navigation guard that handles all the loaders. It has a few options -```ts -setupLoaderGuard(router) -``` +### `app` -You can also pass a second argument for some global options. In [SSR](#ssr), you can also retrieve the fetchedData as its returned value: +The Vue app instance created with `createApp()` -```ts -const fetchedData = setupLoaderGuard( - router, // the router instance for the app - { - // hook triggered before each loader is ran - async beforeLoad(route) { - // route is the target route passed to a loader - // Ensures pinia stores are called with the right context - setActivePinia(pinia) - - // all loaders will await for this to be run before executing - await someOperation - }, +### `router` - // initial data for SSR, see the section below - initialData: { - // ... - }, +The Vue Router instance. - // returns the result of the navigation that should be returned by the navigation guard - // see the section below for more details about "Navigation Results" - selectNavigationResult(results) { - return results[0] - } - } -) -``` +### `initialData` + +Allows setting the initial data and skip the first fetch for [SSR](#ssr) apps. + +### `selectNavigationResult` + +Called wih an array of `NavigationResult` returned by loaders. It allows to decide the _fate_ of the navigation. + +Note this isn't called if no data loaders return a `NavigationResult` or if an error or `NavigationResult` is thrown. In that case, the first throw will take precedence. + +By default, `selectNavigation` returns the first value of the array. + +TODO: move this lower + +## Implementing a custom `defineLoader()` + +The goal if this API is to also expose an implementable interface for external libraries to create more advanced _defineLoaders()_. For example, vue-query could be directly used to create a custom define loader that handles caching and other advanced features. + +## Loaders + +- Discard pending loaders with new navigations even within nested loaders and when discarded loaders resolve later ## Parallel Fetching @@ -283,16 +316,19 @@ import { useUserData } from '@/loaders/users' export const useUserCommonFriends = defineLoader(async (route) => { // loaders must be awaited inside other loaders - // . ⤵ - const { data: user } = await useUserData() // magically works + // . ⤵ + const user = await useUserData() // magically works // fetch other data const commonFriends = await getCommonFriends(user.value.id) - return { ...user.value, commonFriends } + return { ...user, commonFriends } }) ``` -You will notice here that we have two different usages for `useUserData()`, one that returns all the necessary information we need _synchronously_, and a second version that returns the same data but _asynchronously_, in other words, a Promise. This allows to await for the data to be loaded so it can be used directly within the loader. Note you could `await` within `setup()` but **that would be unnecessary** as the loader is already awaited at the navigation layer. +You will notice here that we have two different usages for `useUserData()`: + +- One that returns all the necessary information we need _synchronously_ (not used here). This is the composable that we use in components +- A second version that **only returns a promise of the data**. This is the version used within data loaders ### Nested invalidation @@ -319,29 +355,29 @@ export const useCurrentUserData(async route => { }) export const useUserCommonFriends = defineLoader(async (route) => { - const { data: user } = await useUserData() - const { data: me } = await useCurrentUserData() + const user = await useUserData() + const me = await useCurrentUserData() - const friends = await getCommonFriends(user.value.id, me.value.id) - return { ...me.value, commonFriends: { with: user.value, friends } } + const friends = await getCommonFriends(user.id, me.id) + return { ...me, commonFriends: { with: user, friends } } }) ``` In the example above we are exporting multiple loaders but we don't need to care about the order in which they are called nor try optimizing them because **they are only called once and share the data**. -**Caveat**: must call **and await** all loaders `useUserData()` at the top of the function. You cannot put a different regular `await` in between, it has to be wrapped with a `withDataContext()`: +**Caveat**: must call **and await** all nested loaders at the top of the parent loader (see `useUserData()` and `useCurrentUserData()`). You cannot put a different regular `await` in between. If you really need to await **anything that isn't a loader** in between, wrap the promise with `withDataContext()` to ensure the loader context is properly restored: ```ts export const useUserCommonFriends = defineLoader(async (route) => { - const { data: user } = await useUserData() + const user = await useUserData() await withContext(functionThatReturnsAPromise()) - const { data: me } = await useCurrentUserData() + const me = await useCurrentUserData() // ... }) ``` -This is necessary to ensure nested loaders are aware of their _parent loader_. This could probably be linted with an eslint plugin. It is similar to the problem `<script setup>` had before introducing the automatic `withAsyncContext()`. The same feature could be introduced but will also have a performance cost. +This allows nested loaders to be aware of their _parent loader_. This could probably be linted with an eslint plugin. It is similar to the problem `<script setup>` had before introducing the automatic `withAsyncContext()`. The same feature could be introduced but will also have a performance cost. ## Cache and loader reuse @@ -357,6 +393,8 @@ defineLoader(..., { cacheTime: Infinity }) // Cache forever ## Refreshing the data +TODO: give the loaders the ability to know what was the last location? + When navigating, the data is refreshed **automatically based on what params, query params, and hash** are used within the loader. Given this loader in page `/users/:id`: @@ -389,12 +427,12 @@ export const useUserData = defineLoader( ### Manually refreshing the data -Manually call the `refresh()` function to force the loader to _invalidate_ its cache and _load_ again: +Manually call the `refresh()` function to execute the loader again. Depending on the implementation this will end up in a new request or not. For example, a basic loader could _always_ refetch, while a more advanced one, like [Pinia Colada](https://github.com/posva/pinia-colada) will only refetch if the cached value is _stale_ or if any of the parameters change. ```vue <script setup> import { useInterval } from '@vueuse/core' -import { useUserData } from '~/pages/users/[id].vue' +import { useUserData } from '@/pages/users/[id].vue' const { data: user, refresh } = useUserData() @@ -442,7 +480,7 @@ const { user, post } = toRefs(data.value) ## Usage outside of page components -Loaders are picked up when exported from pages by a navigation guard, **but the page doesn't even need to 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. +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: @@ -457,7 +495,10 @@ Ensure it is **exported** by page components: ```vue <!-- src/pages/users/[id].vue --> <script> -export { useUserData } from '~/loaders/user.ts' +export { useUserData } from '@/loaders/user.ts' +</script> +<script setup> +// ... </script> ``` @@ -466,13 +507,13 @@ You can still use it anywhere else: ```vue <!-- src/components/NavBar.vue --> <script setup> -import { useUserData } from '~/loaders/user.ts' +import { useUserData } from '@/loaders/user.ts' const { data: user } = useUserData() </script> ``` -In such scenarios, it makes more sense to move the loader to a separate file to ensure a more concrete code splitting. +In such scenarios, it makes more sense to move the loader to a separate file to ensure better code splitting. ## TypeScript @@ -540,14 +581,14 @@ export const useUserData = defineLoader( 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 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) will cancel the navigation (same behavior as in a regular navigation guard) and get intercepted by [Vue Router's error handling](https://router.vuejs.org/api/interfaces/router.html#onerror) +- 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 @@ -596,9 +637,31 @@ setupLoaderGuard(router, { `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 will abort, causing any request using it to be aborted as well. +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 }) => { @@ -613,9 +676,11 @@ This aligns with the future [Navigation API](https://github.com/WICG/navigation- To support SSR we need to do two things: -- Pass a `key` to each loader so that it can be serialized into an object later. Would an array work? I don't think the order of execution is guaranteed. +- 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 () => { @@ -663,9 +728,9 @@ One of the advantages of having an initial state is that we can avoid fetching o <!-- TBD: do we need to support this use case? We could allow it by having a `force` option on the loader and passing the initial state in the second argument of the loader. --> -## Performance +## Performance Tip -**When fetching large data sets**, it's convenient to mark the fetched data as _raw_ before returning it: +**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 () => { @@ -700,16 +765,15 @@ TBD: is this worth it? Are any other functions needed? ## Limitations -- Injections (`inject`/`provide`) cannot be used within a loader +- ~~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 -- Global composables like pinia might need special handling (e.g. calling `setActivePinia(pinia)` in a [`beforeLoad()` hook](#setupdatafetchingguard)) # Drawbacks - At first, it looks less intuitive than just awaiting something inside `setup()` with `<Suspense>` [but it doesn't have its limitations](#limitations) -- Requires an extra `<script>` tag but only for views. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function +- Requires an extra `<script>` tag but only for page components. A macro `definePageLoader()`/`defineLoader()` could be error-prone as it's very tempting to use reactive state declared within the component's `<script setup>` but that's not possible as the loader must be created outside of its `setup()` function # Alternatives @@ -754,22 +818,23 @@ onBeforeRouteUpdate(async (to) => { </script> ``` -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. +> [!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 in some navigations a **sequential data fetching**: it's not possible to ensure an **optimal parallel fetching** in all cases -- Manual data refreshing is necessary **unless you add a `key` attribute** to the `<RouterView>` 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 `<transition>` but less flexible than the proposed solution which can also use a `key` if needed. +- 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 `<RouterView>` 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 `<transition>` 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 gather the same data fetching 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** as we still need to render the old page while we _try to mount the new page_. + - 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. +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 @@ -797,11 +862,11 @@ On top of this it's important to note that this RFC doesn't limit you: you can s const route = useRoute() // any variable created here is available in useLoader() const user = await getUserById(route.params.id) - </> + </script> <script lang="ts" setup> const { user, pending, error } = useUserData() - </> + </script> ``` Is exposing every variable a good idea? @@ -838,14 +903,14 @@ Variables could be named differently and proposals are welcome: - `pending` (same as Nuxt) -> `isPending`, `isLoading` - Rename `defineLoader()` to `defineDataFetching()` (or others) -## Nested Invalidation syntax drawbacks +## Nested/Sequential Loaders drawbacks - Allowing `await getUserById()` could make people think they should also await inside `<script setup>` and that would be a problem because it would force them to use `<Suspense>` when they don't need to. -- Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels considerably less ergonomic: +- Another alternative is to pass an array of loaders to the loader that needs them and let it retrieve them through an argument, but it feels _considerably_ less ergonomic: ```ts - import { useUserData } from '~/pages/users/[id].vue' + import { useUserData } from '@/pages/users/[id].vue' export const useUserFriends = defineLoader( async (route, { loaders: [userData] }) => { @@ -861,11 +926,9 @@ Variables could be named differently and proposals are welcome: ## Advanced `lazy` -The `lazy` flag could be extended to also accept a number or a function. I think this is too much and should therefore not be included. - -A lazy loader **always reset the data fields** to `null` when the navigation starts before fetching the data. This is to avoid blocking for a moment (giving the impression data is loading), then showing the old data while the new data is still being fetched and eventually replacing the one being shown to make it even more confusing for the end user. +The `lazy` flag could be extended to also accept a number (timeout) or a function (dynamic value). I think this is too much and should therefore not be included. -Alternatively, you can pass a _number_ to `lazy` to block the navigation for that number of milliseconds: +Passing a _number_ to `lazy` could block the navigation for that number of milliseconds, then let it be: ```vue <script lang="ts"> @@ -889,7 +952,7 @@ const { data, pending, error } = useUserData() 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. -TBD: Conditionally block upon navigation / refresh: +A function could allow to conditionally block upon navigation: ```ts export const useUserData = defineLoader( @@ -911,13 +974,11 @@ Introduce this as part of [unplugin-vue-router](https://github.com/posva/unplugi # Unresolved questions - Should there by an `afterLoad()` hook, similar to `beforeLoad()`? -- Is `useNuxtApp()` usable within loaders? -- Is there anything needed besides the `route` inside loaders? -- Add option for placeholder data? +- 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? -- Is there a way to efficiently parse the exported properties in pages to filter out pages that have named exports but no loaders? -<!-- +<!-- TODO: we could attach an effect scope it each loader, allowing creating reactive variables that are automatically cleaned up when the loader is no longer used by collecting whenever the `useLoader()` fn is called and removing them when the component is unmounted, if the loader is not used anymore, remove the effect scope as well. This requires a way to create the variables so the user can pass a custom composable.