From: Eduardo San Martin Morote Date: Sat, 23 Jan 2021 17:11:45 +0000 (+0100) Subject: feat(view): support suspense X-Git-Tag: v4.0.11~38 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=5003f8ab18d052dc33a996483ddac3c5ccdc115e;p=thirdparty%2Fvuejs%2Frouter.git feat(view): support suspense --- diff --git a/e2e/suspended-router-view/index.html b/e2e/suspended-router-view/index.html new file mode 100644 index 00000000..fb3729b3 --- /dev/null +++ b/e2e/suspended-router-view/index.html @@ -0,0 +1,17 @@ + + + + + + + Vue Router e2e tests - Suspended RouterView + + + + + << Back to Homepage +
+ +
+ + diff --git a/e2e/suspended-router-view/index.ts b/e2e/suspended-router-view/index.ts new file mode 100644 index 00000000..24c8007e --- /dev/null +++ b/e2e/suspended-router-view/index.ts @@ -0,0 +1,96 @@ +import { createRouter, createWebHistory } from '../../src' +import { createApp, defineComponent, onErrorCaptured } from 'vue' + +const delay = (t: number) => new Promise(r => setTimeout(r, t)) + +const Home = defineComponent({ + template: ` +
+

Home

+
`, +}) + +const ViewRegular = defineComponent({ + template: '
Regular
', +}) + +const ViewData = defineComponent({ + template: ` +
+

With Data

+ + + +
+ `, + + async setup() { + await delay(300) + + throw new Error('oops') + + return {} + }, +}) + +const router = createRouter({ + history: createWebHistory('/' + __dirname), + routes: [ + { path: '/', component: Home }, + { + path: '/data', + component: ViewData, + children: [ + { path: '', component: ViewRegular }, + { path: 'data', component: ViewData }, + ], + }, + { + path: '/data-2', + component: ViewData, + children: [ + { path: '', component: ViewRegular }, + { path: 'data', component: ViewData }, + ], + }, + ], +}) + +const app = createApp({ + setup() { + function onPending() { + console.log('onPending') + } + function onResolve() { + console.log('onResolve') + } + function onFallback() { + console.log('onFallback') + } + + onErrorCaptured((err, target, info) => { + console.log('caught', err, target, info) + }) + + return { onPending, onResolve, onFallback } + }, + + template: ` +
+ + + + +
+ `, +}) +app.use(router) + +window.vm = app.mount('#app') +window.r = router diff --git a/src/RouterViewSuspended.ts b/src/RouterViewSuspended.ts new file mode 100644 index 00000000..0d91b9fc --- /dev/null +++ b/src/RouterViewSuspended.ts @@ -0,0 +1,150 @@ +import { + h, + inject, + provide, + defineComponent, + PropType, + ref, + ComponentPublicInstance, + VNodeProps, + computed, + AllowedComponentProps, + ComponentCustomProps, + watch, +} from 'vue' +import { + RouteLocationNormalized, + RouteLocationNormalizedLoaded, + RouteLocationMatched, +} from './types' +import { + matchedRouteKey, + viewDepthKey, + routerViewLocationKey, + suspendedRouteKey, +} from './injectionSymbols' +import { assign } from './utils' +import { isSameRouteRecord } from './location' + +export interface RouterViewProps { + name?: string + // allow looser type for user facing api + route?: RouteLocationNormalized +} + +export const RouterViewSuspendedImpl = /*#__PURE__*/ defineComponent({ + name: 'RouterView', + // #674 we manually inherit them + inheritAttrs: false, + props: { + name: { + type: String as PropType, + default: 'default', + }, + route: Object as PropType, + }, + + setup(props, { attrs }) { + const injectedRoute = inject(routerViewLocationKey)! + const suspendedRoute = inject(suspendedRouteKey)! + 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 + if (from && from !== to && instance && instance === oldInstance) { + to.leaveGuards = from.leaveGuards + 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' } + ) + + 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 null + } + + // 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 + } + } + + const component = h( + ViewComponent, + assign({}, routeProps, attrs, { + onVnodeUnmounted, + ref: viewRef, + }) + ) + + return component + } + }, +}) + +// 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 RouterViewSuspended = (RouterViewSuspendedImpl as any) as { + new (): { + $props: AllowedComponentProps & + ComponentCustomProps & + VNodeProps & + RouterViewProps + } +} diff --git a/src/injectionSymbols.ts b/src/injectionSymbols.ts index 0d345ea1..6cff569c 100644 --- a/src/injectionSymbols.ts +++ b/src/injectionSymbols.ts @@ -54,6 +54,10 @@ export const routeLocationKey = /*#__PURE__*/ PolySymbol( __DEV__ ? 'route location' : 'rl' ) as InjectionKey +export const suspendedRouteKey = /*#__PURE__*/ PolySymbol( + __DEV__ ? 'suspended route location' : 'srl' +) as InjectionKey> + /** * Allows overriding the current route used by router-view. Internally this is * used when the `route` prop is passed. diff --git a/src/router.ts b/src/router.ts index 4895b884..3010ba3a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -61,10 +61,12 @@ import { extractComponentsGuards, guardToPromiseFn } from './navigationGuards' import { warn } from './warning' import { RouterLink } from './RouterLink' import { RouterView } from './RouterView' +import { RouterViewSuspended } from './RouterViewSuspended' import { routeLocationKey, routerKey, routerViewLocationKey, + suspendedRouteKey, } from './injectionSymbols' import { addDevtools } from './devtools' @@ -369,6 +371,7 @@ export function createRouter(options: RouterOptions): Router { START_LOCATION_NORMALIZED ) let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED + const suspendedRoute = shallowRef() // leave the scrollRestoration if no scrollBehavior is provided if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) { @@ -1153,6 +1156,7 @@ export function createRouter(options: RouterOptions): Router { const router = this app.component('RouterLink', RouterLink) app.component('RouterView', RouterView) + app.component('RouterViewSuspended', RouterViewSuspended) app.config.globalProperties.$router = router Object.defineProperty(app.config.globalProperties, '$route', { @@ -1189,6 +1193,7 @@ export function createRouter(options: RouterOptions): Router { app.provide(routerKey, router) app.provide(routeLocationKey, reactive(reactiveRoute)) + app.provide(suspendedRouteKey, suspendedRoute) app.provide(routerViewLocationKey, currentRoute) let unmountApp = app.unmount