From 1191594e9a95cb855d31046ae0df0443c65cadbf Mon Sep 17 00:00:00 2001
From: Eduardo San Martin Morote <posva13@gmail.com>
Date: Fri, 15 Dec 2023 14:27:24 +0100
Subject: [PATCH] perf(RouterView): avoid parent rerenders when possible

Close #1701
---
 packages/playground/src/App.vue               | 110 +++++++-----------
 packages/playground/src/SimpleView.vue        |  48 ++++++++
 packages/playground/src/router.ts             |  12 ++
 .../playground/src/views/RerenderCheck.vue    |  11 ++
 packages/router/src/RouterView.ts             |  39 ++++---
 5 files changed, 138 insertions(+), 82 deletions(-)
 create mode 100644 packages/playground/src/SimpleView.vue
 create mode 100644 packages/playground/src/views/RerenderCheck.vue

diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue
index b686c0c54..b4a618e90 100644
--- a/packages/playground/src/App.vue
+++ b/packages/playground/src/App.vue
@@ -1,3 +1,28 @@
+<script lang="ts" setup>
+import { inject, computed, ref } from 'vue'
+import { useLink, useRoute } from 'vue-router'
+import AppLink from './AppLink.vue'
+import SimpleView from './SimpleView.vue'
+
+const route = useRoute()
+const state = inject('state')
+
+useLink({ to: '/' })
+useLink({ to: '/documents/hello' })
+useLink({ to: '/children' })
+
+const currentLocation = computed(() => {
+  const { matched, ...rest } = route
+  return rest
+})
+
+const nextUserLink = computed(
+  () => '/users/' + String((Number(route.params.id) || 0) + 1)
+)
+
+const simple = ref(false)
+</script>
+
 <template>
   <div>
     <pre>{{ currentLocation }}</pre>
@@ -37,6 +62,11 @@
       <input type="checkbox" v-model="state.cancelNextNavigation" /> Cancel Next
       Navigation
     </label>
+
+    <label>
+      <input type="checkbox" v-model="simple" /> Use Simple RouterView
+    </label>
+
     <ul>
       <li>
         <router-link to="/n/%E2%82%AC">/n/%E2%82%AC</router-link>
@@ -158,76 +188,18 @@
       <li>
         <router-link to="/p_1/absolute-a">/p_1/absolute-a</router-link>
       </li>
+      <li>
+        <RouterLink to="/rerender" v-slot="{ href }">{{ href }}</RouterLink>
+      </li>
+      <li>
+        <RouterLink to="/rerender/a" v-slot="{ href }">{{ href }}</RouterLink>
+      </li>
+      <li>
+        <RouterLink to="/rerender/b" v-slot="{ href }">{{ href }}</RouterLink>
+      </li>
     </ul>
     <button @click="toggleViewName">Toggle view</button>
-    <RouterView :name="viewName" v-slot="{ Component, route }">
-      <Transition
-        :name="route.meta.transition || 'fade'"
-        mode="out-in"
-        @before-enter="flushWaiter"
-        @before-leave="setupWaiter"
-      >
-        <!-- <KeepAlive> -->
-        <Suspense>
-          <template #default>
-            <component
-              :is="Component"
-              :key="route.name === 'repeat' ? route.path : route.meta.key"
-            />
-          </template>
-          <template #fallback> Loading... </template>
-        </Suspense>
-        <!-- </KeepAlive> -->
-      </Transition>
-    </RouterView>
+
+    <SimpleView :simple="simple"></SimpleView>
   </div>
 </template>
-
-<script lang="ts">
-import { defineComponent, inject, computed, ref } from 'vue'
-import { scrollWaiter } from './scrollWaiter'
-import { useLink, useRoute } from 'vue-router'
-import AppLink from './AppLink.vue'
-
-export default defineComponent({
-  name: 'App',
-  components: { AppLink },
-  setup() {
-    const route = useRoute()
-    const state = inject('state')
-    const viewName = ref('default')
-
-    useLink({ to: '/' })
-    useLink({ to: '/documents/hello' })
-    useLink({ to: '/children' })
-
-    const currentLocation = computed(() => {
-      const { matched, ...rest } = route
-      return rest
-    })
-
-    function flushWaiter() {
-      scrollWaiter.flush()
-    }
-    function setupWaiter() {
-      scrollWaiter.add()
-    }
-
-    const nextUserLink = computed(
-      () => '/users/' + String((Number(route.params.id) || 0) + 1)
-    )
-
-    return {
-      currentLocation,
-      nextUserLink,
-      state,
-      flushWaiter,
-      setupWaiter,
-      viewName,
-      toggleViewName() {
-        viewName.value = viewName.value === 'default' ? 'other' : 'default'
-      },
-    }
-  },
-})
-</script>
diff --git a/packages/playground/src/SimpleView.vue b/packages/playground/src/SimpleView.vue
new file mode 100644
index 000000000..0275d952e
--- /dev/null
+++ b/packages/playground/src/SimpleView.vue
@@ -0,0 +1,48 @@
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { scrollWaiter } from './scrollWaiter'
+
+defineProps<{ simple: boolean }>()
+const viewName = ref('default')
+
+function flushWaiter() {
+  scrollWaiter.flush()
+}
+function setupWaiter() {
+  scrollWaiter.add()
+}
+</script>
+
+<template>
+  <RouterView v-if="simple" v-slot="{ Component, route }">
+    <component :is="Component" :key="route.meta.key" />
+  </RouterView>
+
+  <RouterView
+    v-else
+    :name="viewName"
+    v-slot="{ Component, route }"
+    key="not-simple"
+  >
+    <Transition
+      :name="route.meta.transition || 'fade'"
+      mode="out-in"
+      @before-enter="flushWaiter"
+      @before-leave="setupWaiter"
+    >
+      <!-- <KeepAlive> -->
+      <!-- <Suspense>
+        <template #default> -->
+      <!-- <div v-if="route.path.endsWith('/a')">A</div>
+      <div v-else>B</div> -->
+      <component
+        :is="Component"
+        :key="route.name === 'repeat' ? route.path : route.meta.key"
+      />
+      <!-- </template>
+        <template #fallback> Loading... </template>
+      </Suspense> -->
+      <!-- </KeepAlive> -->
+    </Transition>
+  </RouterView>
+</template>
diff --git a/packages/playground/src/router.ts b/packages/playground/src/router.ts
index 981b4192f..1935666a3 100644
--- a/packages/playground/src/router.ts
+++ b/packages/playground/src/router.ts
@@ -15,6 +15,9 @@ import ComponentWithData from './views/ComponentWithData.vue'
 import { globalState } from './store'
 import { scrollWaiter } from './scrollWaiter'
 import RepeatedParams from './views/RepeatedParams.vue'
+import RerenderCheck from './views/RerenderCheck.vue'
+import { h } from 'vue'
+
 let removeRoute: (() => void) | undefined
 
 export const routerHistory = createWebHistory()
@@ -159,6 +162,15 @@ export const router = createRouter({
         { path: 'settings', component },
       ],
     },
+
+    {
+      path: '/rerender',
+      component: RerenderCheck,
+      children: [
+        { path: 'a', component: { render: () => h('div', 'Child A') } },
+        { path: 'b', component: { render: () => h('div', 'Child B') } },
+      ],
+    },
   ],
   async scrollBehavior(to, from, savedPosition) {
     await scrollWaiter.wait()
diff --git a/packages/playground/src/views/RerenderCheck.vue b/packages/playground/src/views/RerenderCheck.vue
new file mode 100644
index 000000000..5c2668442
--- /dev/null
+++ b/packages/playground/src/views/RerenderCheck.vue
@@ -0,0 +1,11 @@
+<script lang="ts" setup>
+import { onUpdated } from 'vue'
+let count = 0
+onUpdated(() => {
+  console.log(`RerenderCheck.vue render: ${++count}`)
+})
+</script>
+
+<template>
+  <RouterView key="fixed" />
+</template>
diff --git a/packages/router/src/RouterView.ts b/packages/router/src/RouterView.ts
index ad4d8f720..6e422be77 100644
--- a/packages/router/src/RouterView.ts
+++ b/packages/router/src/RouterView.ts
@@ -135,12 +135,30 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
       { flush: 'post' }
     )
 
+    let matchedRoute: RouteLocationMatched | undefined
+    let currentName: string
+    // Since in Vue the entering view mounts first and then the leaving unmounts,
+    // we need to keep track of the last route in order to use it in the unmounted
+    // event
+    let lastMatchedRoute: RouteLocationMatched | undefined
+    let lastCurrentName: string
+
+    const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
+      // remove the instance reference to prevent leak
+      if (lastMatchedRoute && vnode.component!.isUnmounted) {
+        lastMatchedRoute.instances[lastCurrentName] = null
+      }
+    }
+
     return () => {
       const route = routeToDisplay.value
+      lastMatchedRoute = matchedRoute
+      lastCurrentName = currentName
       // 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
-      const matchedRoute = matchedRouteRef.value
+      currentName = props.name
+      matchedRoute = matchedRouteRef.value
+
       const ViewComponent =
         matchedRoute && matchedRoute.components![currentName]
 
@@ -149,7 +167,8 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
       }
 
       // props from route configuration
-      const routePropsOption = matchedRoute.props[currentName]
+      // matchedRoute exists since we check with if (ViewComponent)
+      const routePropsOption = matchedRoute!.props[currentName]
       const routeProps = routePropsOption
         ? routePropsOption === true
           ? route.params
@@ -158,13 +177,6 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
           : 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, {
@@ -181,9 +193,10 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({
         // TODO: can display if it's an alias, its props
         const info: RouterViewDevtoolsContext = {
           depth: depth.value,
-          name: matchedRoute.name,
-          path: matchedRoute.path,
-          meta: matchedRoute.meta,
+          // same as above: ensured with if (ViewComponent) above
+          name: matchedRoute!.name,
+          path: matchedRoute!.path,
+          meta: matchedRoute!.meta,
         }
 
         const internalInstances = isArray(component.ref)