--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta http-equiv="X-UA-Compatible" content="ie=edge" />
+ <title>Vue Router e2e tests - Suspense View</title>
+ <!-- TODO: replace with local imports for promises and anything else needed -->
+ <script src="https://polyfill.io/v3/polyfill.min.js?features=default%2Ces2015"></script>
+ </head>
+
+ <body>
+ <a href="/"><< Back to Homepage</a>
+ <hr />
+
+ <main id="app"></main>
+ <script type="module" src="/suspense-view/index.ts"></script>
+ </body>
+</html>
--- /dev/null
+import '../global.css'
+import {
+ createRouter,
+ createWebHistory,
+ onBeforeRouteUpdate,
+ onBeforeRouteLeave,
+ useRoute,
+ SusRouterView,
+} from '../../src'
+import {
+ createApp,
+ ref,
+ reactive,
+ defineComponent,
+ FunctionalComponent,
+ h,
+ onErrorCaptured,
+ defineAsyncComponent,
+} from 'vue'
+
+const Home = defineComponent({
+ name: 'Home',
+ template: `
+ <div>
+ <h2>Home</h2>
+ </div>
+ `,
+})
+
+const delay = (t: number) => new Promise(r => setTimeout(r, t))
+
+const AsyncImport = defineAsyncComponent(async () => {
+ await delay(1000)
+ console.log('finished loading async component')
+ return defineComponent({
+ name: 'AsyncImport',
+ beforeMount() {
+ console.log('done')
+ },
+ template: `<div>AsyncImport</div>`,
+ })
+})
+
+const n = ref(0)
+
+setInterval(() => {
+ n.value++
+}, 1000)
+
+/**
+ * creates a component that logs the guards
+ * @param name
+ */
+function createAsyncComponent(key: string, isAsync = true) {
+ return defineComponent({
+ name: key,
+ components: { AsyncImport },
+ template: `<div id="${key}">${key}: n = {{n}}.<AsyncImport v-if="${isAsync}" /></div>`,
+
+ setup() {
+ const route = useRoute()
+ const shouldFail = !!route.query.fail
+
+ console.log(`Setup of ${key}...`)
+
+ const ret = { n }
+
+ return isAsync
+ ? delay(2000).then(() => {
+ console.log(`finished setup of ${key}`)
+
+ return shouldFail ? Promise.reject(new Error('failed')) : ret
+ })
+ : ret
+ },
+ })
+}
+
+function createAsyncNestedComponent(key: string) {
+ return defineComponent({
+ name: key,
+ template: `<div id="${key}">${key}:
+ <SusRouterView @pending="log('⏳ (nested ${key}) pending', $event)" @resolve="log('✅ (nested ${key}) resolve', $event)">
+ <template #fallback>
+ Loading...
+ </template>
+ <template v-slot="{ Component }">
+ <component :is="Component" class="view" />
+ </template>
+ </SusRouterView>
+ </div>`,
+
+ setup() {
+ const route = useRoute()
+ const shouldFail = !!route.query.fail
+
+ console.log(`Setup of ${key}...`)
+
+ return delay(100).then(() =>
+ shouldFail ? Promise.reject(new Error('failed')) : {}
+ )
+ },
+ })
+}
+
+const Foo = createAsyncComponent('Foo', false)
+const FooAsync = createAsyncComponent('FooAsync')
+const PassThroughView: FunctionalComponent = () => h(SusRouterView)
+PassThroughView.displayName = 'SusRouterView'
+
+const webHistory = createWebHistory('/suspense-view')
+const router = createRouter({
+ history: webHistory,
+ routes: [
+ { path: '/', component: Home },
+ {
+ path: '/foo',
+ component: Foo,
+ },
+ {
+ path: '/foo-async',
+ component: FooAsync,
+ },
+ {
+ path: '/nested',
+ component: PassThroughView,
+ children: [
+ { path: 'foo', component: Foo },
+ { path: 'foo-async', component: FooAsync },
+ ],
+ },
+ {
+ path: '/nested-async',
+ component: createAsyncNestedComponent('NestedAsync'),
+ children: [
+ { path: 'foo', component: Foo },
+ { path: 'foo-async', component: FooAsync },
+ ],
+ },
+ ],
+})
+const shouldFail = ref(false)
+const app = createApp({
+ template: `
+ <h1>Suspense</h1>
+
+ <pre>
+route: {{ $route.fullPath }}
+ </pre>
+
+ <label><input type="checkbox" v-model="shouldFail"> Fail next async</label>
+
+ <button @click="clear()">Clear logs</button>
+
+ <ul>
+ <li><router-link to="/">/</router-link></li>
+ <li><router-link to="/foo">/foo</router-link></li>
+ <li><router-link to="/foo-async">/foo-async</router-link></li>
+ <li><router-link id="update-query" :to="{ query: { n: (Number($route.query.n) || 0) + 1 }}" v-slot="{ route }">{{ route.fullPath }}</router-link></li>
+ <li><router-link to="/nested/foo">Nested with sync child</router-link></li>
+ <li><router-link to="/nested/foo-async">Nested with async child</router-link></li>
+ <li><router-link to="/nested-async/foo">Nested async with sync child</router-link></li>
+ <li><router-link to="/nested-async/foo-async">Nested async with async child</router-link></li>
+ </ul>
+
+ <SusRouterView @pending="log('⏳ pending', $event)" @resolve="log('✅ resolve', $event)">
+ <template #fallback>
+ Loading...
+ </template>
+ <template v-slot="{ Component }">
+ <component :is="Component" class="view" />
+ </template>
+ </SusRouterView>
+ `,
+ setup() {
+ onErrorCaptured(err => {
+ console.log('❌ From Suspense', err)
+ })
+ return { clear: console.clear, shouldFail }
+ },
+})
+app.component('SusRouterView', SusRouterView)
+app.config.globalProperties.log = console.log
+
+router.beforeEach((to, from) => {
+ console.log('-'.repeat(10))
+ console.log(`🏎 ${from.fullPath} -> ${to.fullPath}`)
+ if (shouldFail.value && !to.query.fail)
+ return { ...to, query: { ...to.query, fail: 'yes' } }
+ return
+})
+router.afterEach((to, from, failure) => {
+ if (failure) {
+ console.log(`🛑 ${from.fullPath} -> ${to.fullPath}`)
+ } else {
+ console.log(`🏁 ${from.fullPath} -> ${to.fullPath}`)
+ }
+})
+router.onError((error, to, from) => {
+ console.log(`💥 ${from.fullPath} -> ${to.fullPath}`)
+ console.error(error)
+ console.log('-'.repeat(10))
+})
+app.use(router)
+
+app.mount('#app')
+
+window.r = router
PassThroughViewSuspense.displayName = 'PTVS'
PassThroughViewSuspense.emits = ['pending', 'resolve']
-const webHistory = createWebHistory('/' + __dirname)
+const webHistory = createWebHistory('/suspense')
const router = createRouter({
history: webHistory,
routes: [
- Become part of navigation: the URL should not change until all `<Suspense>` resolve
- Allows the user to display a `fallback` slot and use the `timeout` prop to control when it appears. Note there could be a new RouterView Component that accept those slots and forward them to `Suspense`.
- Abort the navigation when async setup errors and trigger `router.onError()` but still display the current route
+- It shouldn't change the existing behavior when unused
+
+- **Should it also trigger when leaving?** I think it makes more sense for it to trigger only on entering or updating (cf the example below)
### API usage
-```js
+```vue
+<script setup>
import { onBeforeNavigation } from 'vue-router'
import { getUser } from './api'
/**
* This is the component for /users/:id, it fetches the user information and display it.
*/
-export default {
- async setup() {
- const user = ref()
-
- await onBeforeNavigation(async (to, from) => {
- user.value = await getUser(to.params.id)
- })
+const user = ref()
- return { user }
- },
-}
+await onBeforeNavigation(async (to, from) => {
+ user.value = await getUser(to.params.id)
+})
+</script>
```
Let's consider these routes:
This would be the expected behavior:
- Going from `/` to `/users/1` (Entering):
- - Calls `getUser(1)`
- - Keeps Home (`/`) visible until resolves or fails
- - resolves: switch to `/users/1` and display the view with the content ready
+ - Calls `getUser(1)` thanks to `onBeforeNavigation()`
+ - Keeps Home (`/`) visible until it resolves or fails
+ - resolves: finish navigation (triggers `afterEach()`), switch to `/users/1`, and display the view with the content ready
- fails: triggers `router.onError()`, stays at Home
- Going from `/users/1` to `/users/2` (Updating):
- - Calls `getUser(2)`
+ - Also calls `getUser(2)` thanks to `onBeforeNavigation()`
- Keeps User 1 (`/users/1`) visible until resolves or fails
- - resolves: switch to `/users/2` and display the view with the content ready
+ - resolves: (same as above) switch to `/users/2` and display the view with the content ready
- fails: triggers `router.onError()`, stays at User 1
- Going from `/users/2` to `/` (Leaving):
- Directly goes to Home without calling `getUser()`
The implementation for this hook requires displaying multiple router views at the same time: the pending view we are navigating to and the current
+- To avoid
+- We need to wrap every component with Suspense (even nested ones)
+- Multiple Suspenses can resolve but we need to wait for all of them to resolve
+ - `onBeforeNavigation()` could increment a counter
+ - Without it we can only support it in view components: we count `to.matched.length`
+
## Other notes
- RouterView could expose the `depth` (number) alongside `Component` and `route`. It is used to get the matched view from `route.matched[depth]`
--- /dev/null
+import {
+ h,
+ inject,
+ provide,
+ defineComponent,
+ PropType,
+ ref,
+ ComponentPublicInstance,
+ VNodeProps,
+ getCurrentInstance,
+ computed,
+ AllowedComponentProps,
+ ComponentCustomProps,
+ watch,
+ Slot,
+ VNode,
+ Suspense,
+} from 'vue'
+import {
+ RouteLocationNormalized,
+ RouteLocationNormalizedLoaded,
+ RouteLocationMatched,
+} from './types'
+import {
+ matchedRouteKey,
+ viewDepthKey,
+ routerViewLocationKey,
+ pendingViewKey,
+} from './injectionSymbols'
+import { assign, isBrowser } from './utils'
+import { warn } from './warning'
+import { isSameRouteRecord } from './location'
+
+export interface SusRouterViewProps {
+ name?: string
+ // allow looser type for user facing api
+ route?: RouteLocationNormalized
+}
+
+export interface RouterViewDevtoolsContext
+ extends Pick<RouteLocationMatched, 'path' | 'name' | 'meta'> {
+ depth: number
+}
+
+export const SusRouterViewImpl = /*#__PURE__*/ defineComponent({
+ name: 'SusRouterView',
+ // #674 we manually inherit them
+ inheritAttrs: false,
+ props: {
+ name: {
+ type: String as PropType<string>,
+ default: 'default',
+ },
+ route: Object as PropType<RouteLocationNormalizedLoaded>,
+ },
+ emits: ['resolve', 'pending'],
+
+ setup(props, { attrs, slots, emit }) {
+ __DEV__ && warnDeprecatedUsage()
+
+ const injectedRoute = inject(routerViewLocationKey)!
+ const routeToDisplay = computed(() => props.route || injectedRoute.value)
+ const depth = inject(viewDepthKey, 0)
+ const matchedRouteRef = computed<RouteLocationMatched | undefined>(
+ () => routeToDisplay.value.matched[depth]
+ )
+
+ provide(viewDepthKey, depth + 1)
+ provide(matchedRouteKey, matchedRouteRef)
+ provide(routerViewLocationKey, routeToDisplay)
+
+ const viewRef = ref<ComponentPublicInstance>()
+
+ // watch at the same time the component instance, the route record we are
+ // rendering, and the name
+ watch(
+ () => [viewRef.value, matchedRouteRef.value, props.name] as const,
+ ([instance, to, name], [oldInstance, from, oldName]) => {
+ // copy reused instances
+ if (to) {
+ // this will update the instance for new instances as well as reused
+ // instances when navigating to a new route
+ to.instances[name] = instance
+ // the component instance is reused for a different route or name so
+ // we copy any saved update or leave guards. With async setup, the
+ // mounting component will mount before the matchedRoute changes,
+ // making instance === oldInstance, so we check if guards have been
+ // added before. This works because we remove guards when
+ // unmounting/deactivating components
+ if (from && from !== to && instance && instance === oldInstance) {
+ if (!to.leaveGuards.size) {
+ to.leaveGuards = from.leaveGuards
+ }
+ if (!to.updateGuards.size) {
+ to.updateGuards = from.updateGuards
+ }
+ }
+ }
+
+ // trigger beforeRouteEnter next callbacks
+ if (
+ instance &&
+ to &&
+ // if there is no instance but to and from are the same this might be
+ // the first visit
+ (!from || !isSameRouteRecord(to, from) || !oldInstance)
+ ) {
+ ;(to.enterCallbacks[name] || []).forEach(callback =>
+ callback(instance)
+ )
+ }
+ },
+ { flush: 'post' }
+ )
+
+ const addPendingView = inject(pendingViewKey)!
+
+ return () => {
+ const route = routeToDisplay.value
+ const matchedRoute = matchedRouteRef.value
+ const ViewComponent = matchedRoute && matchedRoute.components[props.name]
+ // we need the value at the time we render because when we unmount, we
+ // navigated to a different location so the value is different
+ const currentName = props.name
+
+ if (!ViewComponent) {
+ return normalizeSlot(slots.default, { Component: ViewComponent, route })
+ }
+
+ // props from route configuration
+ const routePropsOption = matchedRoute!.props[props.name]
+ const routeProps = routePropsOption
+ ? routePropsOption === true
+ ? route.params
+ : typeof routePropsOption === 'function'
+ ? routePropsOption(route)
+ : routePropsOption
+ : null
+
+ const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
+ // remove the instance reference to prevent leak
+ if (vnode.component!.isUnmounted) {
+ matchedRoute!.instances[currentName] = null
+ }
+ }
+
+ // FIXME: only because Suspense doesn't emit the initial pending
+ // emit('pending')
+
+ let unregisterPendingView: ReturnType<typeof addPendingView>
+
+ const component = h(
+ Suspense,
+ {
+ onPending: () => {
+ unregisterPendingView = addPendingView(Symbol())
+ emit('pending', String(ViewComponent.name || 'unnamed'))
+ },
+ onResolve: () => {
+ unregisterPendingView && unregisterPendingView()
+ emit('resolve', String(ViewComponent.name || 'unnamed'))
+ },
+ // onResolve,
+ },
+ {
+ fallback: slots.fallback,
+ default: () =>
+ h(
+ ViewComponent,
+ assign({}, routeProps, attrs, {
+ onVnodeUnmounted,
+ ref: viewRef,
+ })
+ ),
+ }
+ )
+
+ if (
+ (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
+ isBrowser &&
+ component.ref
+ ) {
+ // TODO: can display if it's an alias, its props
+ const info: RouterViewDevtoolsContext = {
+ depth,
+ name: matchedRoute.name,
+ path: matchedRoute.path,
+ meta: matchedRoute.meta,
+ }
+
+ const internalInstances = Array.isArray(component.ref)
+ ? component.ref.map(r => r.i)
+ : [component.ref.i]
+
+ internalInstances.forEach(instance => {
+ // @ts-expect-error
+ instance.__vrv_devtools = info
+ })
+ }
+
+ return (
+ // pass the vnode to the slot as a prop.
+ // h and <component :is="..."> both accept vnodes
+ normalizeSlot(slots.default, { Component: component, route }) ||
+ component
+ )
+ }
+ },
+})
+
+function normalizeSlot(slot: Slot | undefined, data: any) {
+ if (!slot) return null
+ const slotContent = slot(data)
+ return slotContent.length === 1 ? slotContent[0] : slotContent
+}
+
+// export the public type for h/tsx inference
+// also to avoid inline import() in generated d.ts files
+/**
+ * Component to display the current route the user is at.
+ */
+export const SusRouterView = SusRouterViewImpl as unknown as {
+ new (): {
+ $props: AllowedComponentProps &
+ ComponentCustomProps &
+ VNodeProps &
+ SusRouterViewProps
+
+ $slots: {
+ default: (arg: {
+ Component: VNode
+ route: RouteLocationNormalizedLoaded
+ }) => VNode[]
+ }
+ }
+}
+
+// warn against deprecated usage with <transition> & <keep-alive>
+// due to functional component being no longer eager in Vue 3
+function warnDeprecatedUsage() {
+ const instance = getCurrentInstance()!
+ const parentName = instance.parent && instance.parent.type.name
+ if (
+ parentName &&
+ (parentName === 'KeepAlive' || parentName.includes('Transition'))
+ ) {
+ const comp = parentName === 'KeepAlive' ? 'keep-alive' : 'transition'
+ warn(
+ `<router-view> can no longer be used directly inside <transition> or <keep-alive>.\n` +
+ `Use slot props instead:\n\n` +
+ `<router-view v-slot="{ Component }">\n` +
+ ` <${comp}>\n` +
+ ` <component :is="Component" />\n` +
+ ` </${comp}>\n` +
+ `</router-view>`
+ )
+ }
+}
export type { RouterLinkProps, UseLinkOptions } from './RouterLink'
export { RouterView } from './RouterView'
export type { RouterViewProps } from './RouterView'
+export { SusRouterView } from './SusRouterView'
+export type { SusRouterViewProps } from './SusRouterView'
export * from './useApi'
export const routerViewLocationKey = /*#__PURE__*/ PolySymbol(
__DEV__ ? 'router view location' : 'rvl'
) as InjectionKey<Ref<RouteLocationNormalizedLoaded>>
+
+export const pendingViewKey = /*#__PURE__*/ PolySymbol(
+ __DEV__ ? 'pending view' : 'pv'
+) as InjectionKey<(view: any) => () => void>
reactive,
unref,
computed,
+ ShallowRef,
} from 'vue'
import { RouteRecord, RouteRecordNormalized } from './matcher/types'
import {
import { RouterLink } from './RouterLink'
import { RouterView } from './RouterView'
import {
+ pendingViewKey,
routeLocationKey,
routerKey,
routerViewLocationKey,
/**
* Current {@link RouteLocationNormalized}
*/
- readonly currentRoute: Ref<RouteLocationNormalizedLoaded>
+ readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>
+
+ readonly pendingNavigation: ShallowRef<
+ null | undefined | Promise<NavigationFailure | void | undefined>
+ >
+
/**
* Original options object passed to create the Router
*/
START_LOCATION_NORMALIZED
)
let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED
+ const pendingViews = new Set<any>()
+ const pendingNavigation = shallowRef<
+ undefined | null | ReturnType<Router['push']>
+ >()
+ let valueToResolveOrError: any
+ let resolvePendingNavigation: (resolvedValue: any) => void = noop
+ let rejectPendingNavigation: (error: any) => void = noop
+
+ function addPendingView(view: any) {
+ pendingViews.add(view)
+
+ return () => {
+ pendingViews.delete(view)
+ if (!pendingViews.size) {
+ resolvePendingNavigation(valueToResolveOrError)
+ }
+ }
+ }
// leave the scrollRestoration if no scrollBehavior is provided
if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
)
}
- return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
- .catch((error: NavigationFailure | NavigationRedirectError) =>
- isNavigationFailure(error)
- ? error
- : // reject any unknown error
- triggerError(error, toLocation, from)
- )
- .then((failure: NavigationFailure | NavigationRedirectError | void) => {
- if (failure) {
- if (
- isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
- ) {
- if (
- __DEV__ &&
- // we are redirecting to the same location we were already at
- isSameRouteLocation(
- stringifyQuery,
- resolve(failure.to),
- toLocation
- ) &&
- // and we have done it a couple of times
- redirectedFrom &&
- // @ts-expect-error: added only in dev
- (redirectedFrom._count = redirectedFrom._count
- ? // @ts-expect-error
- redirectedFrom._count + 1
- : 1) > 10
- ) {
- warn(
- `Detected an infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow. This will break in production if not fixed.`
- )
- return Promise.reject(
- new Error('Infinite redirect in navigation guard')
- )
- }
+ pendingViews.clear()
- return pushWithRedirect(
- // keep options
- assign(locationAsObject(failure.to), {
- state: data,
- force,
- replace,
- }),
- // preserve the original redirectedFrom if any
- redirectedFrom || toLocation
- )
- }
- } else {
- // if we fail we don't finalize the navigation
- failure = finalizeNavigation(
- toLocation as RouteLocationNormalizedLoaded,
- from,
- true,
- replace,
- data
+ return (pendingNavigation.value = new Promise(
+ (promiseResolve, promiseReject) => {
+ rejectPendingNavigation = promiseReject
+
+ return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
+ .catch((error: NavigationFailure | NavigationRedirectError) =>
+ isNavigationFailure(error)
+ ? error
+ : // reject any unknown error
+ triggerError(error, toLocation, from)
)
- }
- triggerAfterEach(
- toLocation as RouteLocationNormalizedLoaded,
- from,
- failure
- )
- return failure
- })
+ .then(
+ (failure: NavigationFailure | NavigationRedirectError | void) => {
+ if (failure) {
+ if (
+ isNavigationFailure(
+ failure,
+ ErrorTypes.NAVIGATION_GUARD_REDIRECT
+ )
+ ) {
+ if (
+ __DEV__ &&
+ // we are redirecting to the same location we were already at
+ isSameRouteLocation(
+ stringifyQuery,
+ resolve(failure.to),
+ toLocation
+ ) &&
+ // and we have done it a couple of times
+ redirectedFrom &&
+ // @ts-expect-error: added only in dev
+ (redirectedFrom._count = redirectedFrom._count
+ ? // @ts-expect-error
+ redirectedFrom._count + 1
+ : 1) > 10
+ ) {
+ warn(
+ `Detected an infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow. This will break in production if not fixed.`
+ )
+ return Promise.reject(
+ new Error('Infinite redirect in navigation guard')
+ )
+ }
+
+ // FIXME: find a way to keep the return pattern of promises to handle reusing the promise maybe by
+ // passing the resolve, reject as parameters to pushWithRedirect
+
+ return pushWithRedirect(
+ // keep options
+ assign(locationAsObject(failure.to), {
+ state: data,
+ force,
+ replace,
+ }),
+ // preserve the original redirectedFrom if any
+ redirectedFrom || toLocation
+ )
+ }
+ } else {
+ // if we fail we don't finalize the navigation
+ pendingNavigation.value = null
+ failure = finalizeNavigation(
+ toLocation as RouteLocationNormalizedLoaded,
+ from,
+ true,
+ replace,
+ data
+ )
+ }
+ resolvePendingNavigation = () => {
+ triggerAfterEach(
+ toLocation as RouteLocationNormalizedLoaded,
+ from,
+ failure as any
+ )
+ promiseResolve(failure as any)
+ }
+ return failure
+ }
+ )
+ }
+ ))
}
/**
// navigation is confirmed, call afterGuards
// TODO: wrap with error handlers
for (const guard of afterGuards.list()) guard(to, from, failure)
+
+ // TODO: moving this here is technically a breaking change maybe as it would mean the afterEach trigger before any
+ // afterEach but I think it's rather a fix.
+ // FIXME: this breaks a lot of tests
+ markAsReady()
}
/**
// accept current navigation
currentRoute.value = toLocation
handleScroll(toLocation, from, isPush, isFirstNavigation)
-
- markAsReady()
}
let removeHistoryListener: () => void | undefined
const router: Router = {
currentRoute,
+ pendingNavigation,
addRoute,
removeRoute,
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
+ app.provide(pendingViewKey, addPendingView)
const unmountApp = app.unmount
installedApps.add(app)