+
`,
setup() {
- return { state, logs, log: console.log, shouldFail }
+ onErrorCaptured(error => {
+ console.warn('Error captured', error)
+ rejectPending(error)
+ return false
+ })
+
+ function resolvePending() {
+ console.log('resolve')
+ pendingContext?.resolve()
+ }
+
+ function rejectPending(err: any) {
+ pendingContext?.reject(err)
+ }
+
+ return {
+ state,
+ logs,
+ log: console.log,
+ resolvePending,
+ shouldFail,
+ // TODO: remove after showing tests
+ TODO: true,
+ createPendingContext,
+ }
},
})
router.beforeEach(to => {
+ console.log('beforeEach')
if (shouldFail.value && !to.query.fail)
return { ...to, query: { ...to.query, fail: 'yes' } }
return
@@ -141,3 +210,37 @@ app.use(router)
window.r = router
app.mount('#app')
+
+// code to handle the pending context on suspense
+
+router.beforeEach(async () => {
+ // const pending = createPendingContext()
+ // await pending.promise
+})
+
+interface PendingContext {
+ resolve(): void
+ reject(error?: any): void
+
+ promise: Promise
+}
+
+let pendingContext: PendingContext | null = null
+
+function createPendingContext(): PendingContext {
+ // reject any ongoing pending navigation
+ if (pendingContext) {
+ pendingContext.reject(new Error('New Navigation'))
+ }
+
+ let resolve: PendingContext['resolve']
+ let reject: PendingContext['reject']
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+
+ pendingContext = { promise, resolve: resolve!, reject: reject! }
+
+ return pendingContext
+}
diff --git a/e2e/suspense/notes.md b/e2e/suspense/notes.md
new file mode 100644
index 000000000..7bf775fa0
--- /dev/null
+++ b/e2e/suspense/notes.md
@@ -0,0 +1,196 @@
+# Suspense + Router View
+
+- Ignore the log of navigation guards
+
+When toggling between two routes (or components), if one is async and we are using Suspense around it, Suspense will display the current component until the pending one resolves. **This is the desired behavior**. Can we make it work the same for nested async components?
+
+This is the [current code](https://github.com/nuxt/framework/blob/main/packages/pages/src/runtime/page.vue) for ``:
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Right now it pretty much replaces `` in Nuxt apps, so it's used for nested pages as well
+
+## Async Views not linked to navigation
+
+Right now, the router will navigate to a location and only then, `async setup` will run. This can still be fine as users do not necessarily need to put fetching logic inside navigation (Although I think it makes more sense unless you are handling the loading state manually).
+
+### Advantages
+
+- Simplest code as it only requires changes in the `template`:
+ ```html
+
+
+
+
+
+ ```
+ `pendingHandler` and `resolveHandler` are completely optional
+- Displays the previous view while the new one is pending
+
+### Problems / Solutions
+
+- Can't handle Nested Routes [#Problem 1](#problem-1)
+- Route updates eagerly, which can be confusing and is different from fetching during navigation
+ - This cannot be solved without [a proper integration with vue router](#onBeforeNavigation)
+- Errors (technically Promise rejections) still try displaying the async component that failed
+ - **Possible solutions**:
+ - An `error` slot to let the user show something
+ - Show (or rather stay with) the previous content if no `error` slot is provided because it's very likely to fail anyway and error something harder to debug because of initialized/inexistent properties
+ - An `error` event
+
+### Questions
+
+- Is the `fallback` event really useful? Isn't `pending` enough? What is the point of knowing when `fallback` is displayed.
+- Could we have a new nested pending event that lets us know when any nested suspense goes pending? Same for nested resolve and maybe error. These events would have to be emitted once if multiple nested suspense go pending at the same tick.
+
+---
+
+Right now the Router doesn't listen for any events coming from suspense. The e2e test in this folder is meant to listen and play around with a possible integration of Vue Router with Suspense.
+
+## Use case
+
+This is a more realistic scenario of data fetching in `async setup()`:
+
+```js
+export default {
+ async setup() {
+ const route = useRoute()
+ // any error could be caught by navigation guards if we listen to `onErrorCaptured()
+ const user = ref(await getUser(route.params.id))
+
+ return { user }
+ },
+}
+```
+
+## Problem 1
+
+Run the example with `yarn run dev:e2e` and go to [the suspense test page](http://localhost:8080/suspense). It has a few views that aren't async, nested views **without nested Suspense** and nested views **with nested suspense**.
+
+All async operations take 1s to resolve. They can also reject by adding `?fail=yes` to the URL or by checking the only checkbox on the page.
+The example is also displaying a fallback slot with a 500ms timeout to see it midway through the pending phase.
+
+Right now, these behaviors are undesired:
+
+- Going from `/nested/one` to `/nested/two` displays a blank view while nested two loads. It should display one and then _loading root_ instead while pending
+- Going from `/nested/one` to `/async-foo` displays a blank view while async foo loads. It should display one instead before displaying _loading root_ while pending
+- Going from `/nested-suspense/one` to `/foo-async` displays a blank view while foo async loads. It should display one before displaying _loading root_. It also creates a weird error `Invalid vnode type when creating vnode`:
+ ```
+ runtime-core.esm-bundler.js:38 [Vue warn]: Invalid vnode type when creating vnode: undefined.
+ at
+ at ref=Ref<
Loading nested...
> class="view" >
+ at
+ at
+ ```
+- Going from `/foo-async` to `/nested-suspense/one` displays _loading nested_ right away instead of displaying foo async for 500ms (as if we were initially visiting)
+- Going from `/nested-suspense/one` to `/nested/two` breaks the application by leaving a loading node there.
+
+Ideas:
+
+- Would it be possible to display the whole current tree when a child goes _pending_? We could still display their nested fallback slots if they have any
+- Is this possible without using nested Suspense? I think it's fine to require users to use nested Suspense with nested views. It could be abstracted anyway (e.g. with NuxtPage).
+
+## onBeforeNavigation
+
+> ⚠️ : this is a different problem from the one above
+
+This is an idea of integrating better with Suspense and having one single navigation guard that triggers on enter and update. It goes further and the idea is to move to more intuitive APIs and keep improving vue router. Right now, we can achieve something similar with a `router.beforeResolve()` hook and saving data in `to.meta` but it disconnects the asynchronous operation from the view component and therefore is limited and not intuitive.
+
+### Needs
+
+- Become part of navigation: the URL should not change until all `` 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
+
+```vue
+
+```
+
+
+
+Let's consider these routes:
+
+- Home `/`: Not Async
+- User `/users/:id`: Async, should fetch the user data to display it
+
+This would be the expected behavior:
+
+- Going from `/` to `/users/1` (Entering):
+ - 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):
+ - Also calls `getUser(2)` thanks to `onBeforeNavigation()`
+ - Keeps User 1 (`/users/1`) visible until resolves or fails
+ - 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()`
+
+## Pros
+
+- Fully integrates with the router navigation
+ - Allows global loaders (progress bar) to be attached to `beforeEach()`, `afterEach()`, and `onError()`
+
+## Cons
+
+- Complex implementation
+- Could be fragile and break easily too (?)
+
+## Implementation
+
+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]`
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
index d61bd724c..a711ec100 100644
--- a/e2e/tsconfig.json
+++ b/e2e/tsconfig.json
@@ -1,7 +1,7 @@
{
"include": ["index.ts", "*/*.ts", "../src/global.d.ts"],
"compilerOptions": {
- "target": "es6",
+ "target": "ESNext",
"module": "commonjs",
// "lib": ["es2017.object"] /* Specify library files to be included in the compilation. */,
"declaration": true,
diff --git a/src/SusRouterView.ts b/src/SusRouterView.ts
new file mode 100644
index 000000000..2f0dd81cd
--- /dev/null
+++ b/src/SusRouterView.ts
@@ -0,0 +1,262 @@
+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 {
+ depth: number
+}
+
+export const SusRouterViewImpl = /*#__PURE__*/ defineComponent({
+ name: 'SusRouterView',
+ // #674 we manually inherit them
+ inheritAttrs: false,
+ props: {
+ name: {
+ type: String as PropType,
+ default: 'default',
+ },
+ timeout: Number,
+ route: Object as PropType,
+ },
+ emits: ['resolve', 'pending', 'fallback'],
+
+ 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(
+ () => routeToDisplay.value.matched[depth]
+ )
+
+ provide(viewDepthKey, depth + 1)
+ provide(matchedRouteKey, matchedRouteRef)
+ provide(routerViewLocationKey, routeToDisplay)
+
+ const viewRef = ref()
+
+ // 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
+
+ const component = h(
+ Suspense,
+ {
+ timeout: props.timeout,
+ onPending: () => {
+ unregisterPendingView = addPendingView(Symbol())
+ emit('pending', String(ViewComponent.name || 'unnamed'))
+ },
+ onResolve: () => {
+ unregisterPendingView && unregisterPendingView()
+ emit('resolve', String(ViewComponent.name || 'unnamed'))
+ },
+ onFallback: () => {
+ emit('fallback', String(ViewComponent.name || 'unnamed'))
+ },
+ },
+ {
+ 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 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 &
+// 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(
+ ` can no longer be used directly inside or .\n` +
+ `Use slot props instead:\n\n` +
+ `\n` +
+ ` <${comp}>\n` +
+ ` \n` +
+ ` ${comp}>\n` +
+ ``
+ )
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index adfd8207e..ce85d4039 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -71,6 +71,8 @@ export { RouterLink, useLink } from './RouterLink'
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'
diff --git a/src/injectionSymbols.ts b/src/injectionSymbols.ts
index 0d345ea1f..633cd3371 100644
--- a/src/injectionSymbols.ts
+++ b/src/injectionSymbols.ts
@@ -63,3 +63,7 @@ export const routeLocationKey = /*#__PURE__*/ PolySymbol(
export const routerViewLocationKey = /*#__PURE__*/ PolySymbol(
__DEV__ ? 'router view location' : 'rvl'
) as InjectionKey>
+
+export const pendingViewKey = /*#__PURE__*/ PolySymbol(
+ __DEV__ ? 'pending view' : 'pv'
+) as InjectionKey<(view: any) => () => void>
diff --git a/src/router.ts b/src/router.ts
index da3170c5f..f3341ca47 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -50,6 +50,7 @@ import {
reactive,
unref,
computed,
+ ShallowRef,
} from 'vue'
import { RouteRecord, RouteRecordNormalized } from './matcher/types'
import {
@@ -63,6 +64,7 @@ import { warn } from './warning'
import { RouterLink } from './RouterLink'
import { RouterView } from './RouterView'
import {
+ pendingViewKey,
routeLocationKey,
routerKey,
routerViewLocationKey,
@@ -191,7 +193,12 @@ export interface Router {
/**
* Current {@link RouteLocationNormalized}
*/
- readonly currentRoute: Ref
+ readonly currentRoute: ShallowRef
+
+ readonly pendingNavigation: ShallowRef<
+ null | undefined | Promise
+ >
+
/**
* Original options object passed to create the Router
*/
@@ -370,6 +377,24 @@ export function createRouter(options: RouterOptions): Router {
START_LOCATION_NORMALIZED
)
let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED
+ const pendingViews = new Set()
+ const pendingNavigation = shallowRef<
+ undefined | null | ReturnType
+ >()
+ 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) {
@@ -679,12 +704,20 @@ export function createRouter(options: RouterOptions): Router {
)
}
- return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
- .catch((error: NavigationFailure | NavigationRedirectError) =>
- isNavigationFailure(error)
- ? error
- : // reject any unknown error
- triggerError(error, toLocation, from)
+ // reset any pending views
+ pendingViews.clear()
+
+ return (pendingNavigation.value = (
+ failure ? Promise.resolve(failure) : navigate(toLocation, from)
+ )
+ .catch(
+ (
+ errorOrNavigationFailure: NavigationFailure | NavigationRedirectError
+ ) =>
+ isNavigationFailure(errorOrNavigationFailure)
+ ? errorOrNavigationFailure
+ : // triggerError returns a rejected promise to avoid the next then()
+ triggerError(errorOrNavigationFailure, toLocation, from)
)
.then((failure: NavigationFailure | NavigationRedirectError | void) => {
if (failure) {
@@ -715,6 +748,9 @@ export function createRouter(options: RouterOptions): Router {
)
}
+ // 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), {
@@ -726,8 +762,10 @@ export function createRouter(options: RouterOptions): Router {
redirectedFrom || toLocation
)
}
+ // we had a failure so we cannot change the URL
} else {
- // if we fail we don't finalize the navigation
+ // Nothing prevented the navigation to happen, we can update the URL
+ pendingNavigation.value = null
failure = finalizeNavigation(
toLocation as RouteLocationNormalizedLoaded,
from,
@@ -736,13 +774,23 @@ export function createRouter(options: RouterOptions): Router {
data
)
}
- triggerAfterEach(
- toLocation as RouteLocationNormalizedLoaded,
- from,
- failure
- )
- return failure
- })
+ // we updated the URL and currentRoute, this will update the rendered router view
+ // we move into the second phase of a navigation: awaiting suspended routes
+ return new Promise((promiseResolve, promiseReject) => {
+ // FIXME: if we reject here we need to restore the navigation like we do in the setupListeners
+ // this would be so much easier with the App History API
+ rejectPendingNavigation = promiseReject
+ resolvePendingNavigation = () => {
+ triggerAfterEach(
+ toLocation as RouteLocationNormalizedLoaded,
+ from,
+ failure as any
+ )
+ promiseResolve(failure as any)
+ }
+ valueToResolveOrError = failure
+ })
+ }))
}
/**
@@ -888,6 +936,11 @@ export function createRouter(options: RouterOptions): Router {
// 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()
}
/**
@@ -931,8 +984,6 @@ export function createRouter(options: RouterOptions): Router {
// accept current navigation
currentRoute.value = toLocation
handleScroll(toLocation, from, isPush, isFirstNavigation)
-
- markAsReady()
}
let removeHistoryListener: () => void | undefined
@@ -1140,6 +1191,7 @@ export function createRouter(options: RouterOptions): Router {
const router: Router = {
currentRoute,
+ pendingNavigation,
addRoute,
removeRoute,
@@ -1202,6 +1254,7 @@ export function createRouter(options: RouterOptions): Router {
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
+ app.provide(pendingViewKey, addPendingView)
const unmountApp = app.unmount
installedApps.add(app)