Suspense in Vue 3 on nested routes - why content disappears?

2k Views Asked by At

I'm using Suspense, and fetching async data on pages. On init fallback loading indicator is visible, but while switching pages I'm keeping current page content until new page is ready + nprogress bar, so site behaves more like SSR. There is also Transition between, but it's not important for that issue.

And it works great. Problem is when my page has child pages (nested routes), also with Suspense. When I'm on nested page like /demo/demo1 and navigate to home, content of /demo template stays like it should, but content of subpage demo1 disappears. I need to keep nested Suspense until parent Suspense is ready. Any ideas?

I simplified code as much as possible. I'm using same <RouterView> code on both root App and page.

<RouterView v-slot="{ Component }">
  <template v-if="Component">
    <Suspense>
      <component :is="Component" />
      <template #fallback>
        Loading...
      </template>
    </Suspense>
  </template>
</RouterView>

Here is live reproduction: https://stackblitz.com/edit/vitejs-vite-gg6kqo?file=src/pages/demo/demo1.vue (there is no nprogress, just wait 1s between pages).

Steps to reproduction:

  1. Click on "About" - "home page" stays until "about" is ready, and then "about" appears - works OK.
  2. Click on "Demo" - "demo" root page appears, fallback for nested "demo/demo1" route appears - works OK.
  3. Click on "Home" - content of "demo/demo1" immediately disappears.
2

There are 2 best solutions below

1
On BEST ANSWER

I wouldn't say this to be the best answer, but it seems to me that in order for <Suspense> to work, its child must be an asynchronous component. In this case, <RouterView> is rendered as a non-asynchronous component (even though its children are asynchronous components), thus the root <Suspense> wouldn't wait for the <Suspense> inside the <RouterView>.

As a workaround, I think we can cache the component, so that it wouldn't look as if it's gone when the router is changed (the third case).

My method of caching is by adding this <script setup> in the demo.vue file:

<script setup>
import { ref } from 'vue';

const CachedComponent = ref(null);

function cacheComponent(Component) {
  CachedComponent.value = Component || CachedComponent.value;
}
</script>

And then modify the <RouterView> inside the same file to this:

    ...
    <RouterView v-slot="{ Component }">
      {{ cacheComponent(Component) }}
      <template v-if="Component || CachedComponent">
        <Suspense>
          <component :is="Component || CachedComponent" />
          <template #fallback>
            <div style="font-size: 1.5rem">Loading Demo Nested...</div>
          </template>
        </Suspense>
      </template>
    </RouterView>
    ...

The {{ cacheComponent(Component) }} line will cache the component when it's rendered (it's used that way because <RouterView> doesn't have callback when its slots are mounted). The CachedComponent.value = Component || CachedComponent.value; line will update the CachedComponent value if there is a new Component that is not falsy. And if it's falsy (meaning that it's not mounted yet or not mounted anymore), it will render the previous latest cached version of the component. I know this is hacky, but in my opinion, this is a very simple workaround.

This is the forked stackblitz if you're interested.

0
On

Update Vue 3.3.2 - 17/05/2023

I just tested out this version and the nested router pages with async setup are working.

From what I can see, nested router pages pages are no longer displaying blank pages with a console warning about needing to be wrapped in Suspense.

Instead the top level Suspense is now handling the child pages without wrongly suggesting that there needed to be a Suspense around the nested RouterView.

// App.vue
<RouterView v-slot="{ Component, route }">
    <template v-if="Component">
        <KeepAlive>
            <Suspense>
                <component :is="Component"/>
                <template #fallback>
                    LOADING...
                </template>
            </Suspense>
        </KeepAlive>
    </template>
</RouterView>

// pages/nested-path/index.vue

<div>
  <RouterView v-slot="{ Component, route }">
    <component :is="Component" :key="route.path"/>
  </RouterView>
</div>

Previous Response

It simply does not work at this time for complex projects when you have nested routes, it works fine with routing on a single level as you have noticed.

I battled with this for a while, tried many combinations and found nested suspense is broken, it is to do with a breakdown in the functionality around the key that is passively used for the router component, I tried setting this manually which yielded various results and other unfixable/annoying problems including any use with keep-alive.

Not being able to use async setup was really annoying and unfortunate especially for suspending while negotiating other promises before attempting an API call, I especially like how clean the code would have been.

I had to settle for async watch where I set a loading = ref(true) state passed to a custom loading state component wrapper that would I/O block content and show a loading progress in the content body.

// myPage.vue

<script setup lang="ts">

const props = defineProps<{
    id: string
}>()

const loading = ref(true)

watch(props, async () => {

  loading.value = true

  await myApi.endpoint.get(props)

  loading.value = false

}, {immediate: true})

</script>

<template>
  <content-loader :loading="loading">
    <div>
      My page content
    </div>
  </content-loader>
</template>
// ContentLoader.vue

<script setup lang="ts">

withDefaults(defineProps<{
    loading: boolean
}>(), {
    loading: true
})

</script>

<template>

    <div class="d-flex fill-height justify-center align-center">

        <template v-if="$props.loading">

            <span>Content loading...

        </template>

        <template v-else>
            <slot/>
        </template>

    </div>

</template>

You could slap transitions in there too if you wanted.

I was also thinking about re-deducing repetition of the loading wrapper and loading reference by moving it to the parent component hosting the child router-view and using emit in the child page to toggle loading. I haven't tried this, need to ensure the initial state is true when transitioning to other pages.