Quasar: Hydration error SSR with async components

Created on 28 Feb 2020  路  18Comments  路  Source: quasarframework/quasar

Describe the bug
In relation to #6296 there is still an open bug, so I'm creating a new issue for this. We use a lot of async components in our application and we would also like to use async layouts, but when we do that we get hydration errors.

Codepen/jsFiddle/Codesandbox (required)
https://codesandbox.io/s/floral-river-dwfo3

To Reproduce
Steps to reproduce the behavior:

  1. Make sure the browser window in the sandbox is wide enough so that the left drawer is visible.
  2. Reload the page and look in the console

I changed the App.vue to load in the MainLayout async instead of using the router view. Otherwise no changes compared to the original sandbox.

Expected behavior
No hydration errors

bug

All 18 comments

Is there anyone who can look into this? Seems like a pretty big issue to me.

Checked and the issue still persists with the latest version of Quasar. Any chance this can get a look?
@rstoenescu or @pdanpdan maybe?

The only solution I see for that is to select the dynamic components in the preFetch function of the App - this way the render should wait for it to be available - but I haven't checked if it's possible.
It would help us if you could test and report back.

I wasn't able to reproduce

image

so i tried to create a new project...

boot/components.js

import Vue from 'vue'

Vue.component('global-import', () => import('components/global-import'))

boot/global-import.vue

<q-card class="q-pa-xs q-ma-xs">Global Component</q-card>

boot/component-import.vue

<q-card class="q-pa-xs q-ma-xs">Component Import</q-card>

pages/Index.vue

<q-page class="flex flex-center">
  <img alt="Quasar logo" src="~assets/quasar-logo-full.svg" >
  <global-import></global-import>
  <component-import></component-import>
</q-page>
export default {
  name: 'PageIndex',
  components: {
    'component-import': () => import('components/component-import.vue')
  }
}

result:

image

@TobyMosque His case is not a normal one - a wrapper component is used to select another component based on the return value of some async call, and this chain is so complex that at least I haven't been able to follow it.

so him would do something like:
boot/layouts.js

Import Vue from 'vue'
Vue.component('layout-client-a', () => import('layouts/client-a.vue'))
Vue.component('layout-client-b', () => import('layouts/client-b.vue'))
Vue.component('layout-client-c', () => import('layouts/client-c.vue'))

App.vue

<div id="q-app">
  <component :is="layout"></component>
</div>
import axiosInstance from 'axios'
export default {
  async preFetch ({ store }) {
    const { data } = await axiosInstance.get('getLayout')
    store.commit('app/layout', data)
  },
  computed: {
    layout () {
      return this.$store.state.app.layout
    }
  }
}

@TobyMosque To reproduce you have to make the inner browser window in there so wide that the left drawer becomes visible. Then hit refresh in there and check the console.

It happens only with some components, QDrawer at least. That has some SSR logic which is applied client side to match the result of the SSR (as resolution is 0 there). But this logic is applied to late because of the async component.

image

@TobyMosque Were you able to reproduce now?

@TobyMosque Any chance you could follow up on this one please?

I'm not 100% sure, but i think that would be handled by the vue-router.

if you check the vue SSR tutorial, you'll notice that line:

    // wait until router has resolved possible async components and hooks
    router.onReady(() => {
      ...
    }, reject)

https://ssr.vuejs.org/guide/routing.html#routing-with-vue-router

So u can ask them if the router can wait for all lazy components, not only that are defined at the routes.

One alternative, is load your components in a boot or in the preFetch:

export default async function ({ app, store }) {
  const { data: layout } = await app.axios.get('layout')
  store.commit('app/layout', layout)
  if (!([store.state.app.layout] in Vue.options.components)) {
    let component
    switch (store.state.app.layout) {
      case 'layout-a': component = await import('components/LayoutA.vue')
      case 'layout-b': component = await import('components/LayoutB.vue')
    }
    Vue.component(store.state.app.layout, component.default)
  }
}

and in your App.vue

  <div id="q-app">
    <component :is="$store.state.app.layout" />
  </div>

@TobyMosque I already asked that at the vue-router repository, but it seems not possible: https://github.com/vuejs/vue-router/issues/3142

I will try to see if I can load in the layout within the router setup file.

Maybe some explanation about our exact setup.
We use a 'themePark' function to import components async based on some path. So we would do themePark.import("Layout") and it would import the Layout.vue file in a specific folder, based on what theme we are currently on. This theme is defined by the API and that is loaded in a boot file. This themepark function is also initialized in a boot file. I already looked at doing something in a preFetch hook but there we don't have access to anything we can use to get to that themepark function.

I have one idea I haven't tried yet and that is to set that themepark function onto the 'store' in a boot file and then access that in the router setup.

If that works it is still only a bandaid for the problem :( We then still have to put all the QDrawers and other layout components that cause hydration errors in that single Layout file, we cannot partition them up in more asynchronous components, as we would like to do to keep things separated and tidy.

Ok, I am able to move the layouts to our themepark and import them from within the routes. That is atleast a good workaround for our problem. But I just checked, that still only works 1 level deep. So the file you dynamically import in the router at the route configuration has to contain the q-drawers and other layout components to prevent hydration errors.

Is it maybe possible to expose the moment on which the app resolves, so that you can say after component Y is created, I want my app to mount. Just trying to think of a solution for this problem.

Thanks Tobias, I got this to work:

First, create something called 'app-layout.ts' in boot files:

import Vue from 'vue';
import { boot } from 'quasar/wrappers';
import { Route } from 'vue-router';
import { LAYOUT_DEFAULT } from 'src/layouts';

export function GetLayoutNameFromRoute({ matched }: Route): string
{
    let newLayout: string;

    for (let i = matched.length - 1; i >= 0; i--)
    {
        const { layout } = matched[i].meta as { layout: string };

        if (layout)
        {
            newLayout = layout;
            break;
        }
    }

    if (!newLayout)
        newLayout = LAYOUT_DEFAULT;

    return newLayout;
}

export default boot(({ app }) =>
{
    const { router } = app;

    router.beforeResolve(async (to, from, next) =>
    {
        const layoutName = GetLayoutNameFromRoute(to);

        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        if (!(Vue as any).options.components[layoutName])
        {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            const component = await import(/* webpackChunkName: "lt-" */ `src/layouts/${layoutName}.vue`);
            Vue.component(layoutName, component.default);
        }

        next();
    });
});

Then AppLayout.vue will looks like:

<template>
    <component :is="layoutName" />
</template>

<script lang="ts">
import { Route } from 'vue-router';

import
{
    Vue, Component, Watch
} from 'vue-property-decorator';

import { GetLayoutNameFromRoute } from 'boot/app-layout';
import { LAYOUT_DEFAULT } from 'src/layouts';

@Component({})
export default class AppLayout extends Vue
{
    layoutName: string = LAYOUT_DEFAULT;

    @Watch('$route', { immediate: true })
    onRouteChanged(route: Route)
    {
        const newLayoutName = GetLayoutNameFromRoute(route);

        if (process.env.DEV)
            console.log(`AppLayout: applying '${newLayoutName}' layout.`);

        this.layoutName = newLayoutName;
    }
}
</script>

And now in App.vue:

<template>
    <div id="q-app">
        <app-layout />
    </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import AppLayout from 'components/AppLayout.vue';

@Component({ components: { AppLayout } })
export default class App extends Vue
{ }
</script>

Usage in routes:

import { LAYOUT_MINI } from 'src/layouts';
...
export default [
  {
      path: SignInRoutePaths.ROUTE_SIGNIN,
      component: SignIn,

      meta: {
          layout: LAYOUT_MINI
      }
  }
]

Will be glad for any improvements.

This thing is more closer to initial Quasar idea of layout handling. But we can achieve more flexibility: instead of wrapping, just specify meta.layout at any child level of route, even if parent level has different layout.

Original Quasar code:

const routes: RouteConfig[] = [
  {
    path: '/',
    component: () => import('layouts/MainLayout.vue'),
    children: [
      { path: '', component: () => import('pages/Index.vue') },
    ],
  },
  {
    path: '/sign-in/',
    component: () => import('layouts/MiniLayout.vue'),
    children: [
      { path: '', component: () => import('pages/SignIn.vue') },
    ],
  },
  // Always leave this as last one,
  // but you can also remove it
  {
    path: '*',
    component: () => import('pages/Error404.vue'),
  },
];

export default routes;

Modified with layout addon (DefaultLayout is applying when no meta was specified):

import { RouteConfig } from 'vue-router';

const routes: RouteConfig[] = [
  {
    path: '/',
    component: () => import('pages/Index.vue')
    // Uses DefaultLayout
  },
  {
    path: '/sign-in/',
    component: () => import('pages/SignIn.vue'),
    meta: { layout: 'Mini' } // Uses MiniLayout
  },
  // Always leave this as last one,
  // but you can also remove it
  {
    path: '*',
    component: () => import('pages/Error404.vue'),
    // Also uses DefaultLayout.
  },
];

export default routes;

Seems it working only for SSR mode. For SPA mode we need to change the boot file like this:

async function preloadLayout(to: Route)
{
    const layoutName = GetLayoutNameFromRoute(to);

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    if (!(Vue as any).options.components[layoutName])
    {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const component = await import(/* webpackChunkName: "lt-" */ `src/layouts/${layoutName}Layout.vue`);
        Vue.component(layoutName, component.default);
    }
}

export default boot(async ({ app }) =>
{
    const { router } = app;

    await preloadLayout(router.currentRoute); // Apply to current route when app is starting!

    router.beforeEach(async (to, from, next) =>
    {
        await preloadLayout(to); // Apply to any other route before it navigating.
        next();
    });
});

Layout may blinking from default to target route when startup path contain something more than '/'.

With digging deeper in SPA (I don't like blinking layouts), found that previous solution may cause double creation of target route component. To prevent blinking, we must doing the following:

  1. Remove preloadLayout from startup process of boot file and keep it only in beforeEach.
// src/boot/app-layout.ts
async function preloadLayout(route: Route)
{
    const layoutName = GetLayoutNameFromRoute(route);

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    if (!(Vue as any).options.components[layoutName])
    {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const component = await import(/* webpackChunkName: "lt-" */ `src/layouts/${layoutName}Layout.vue`);
        Vue.component(layoutName, component.default);
    }
}

export default boot(({ app: { router } }) =>
{
    router.beforeEach(async (to, from, next) =>
    {
        await preloadLayout(to);
        next();
    });
});

~2. Remove {immediate: true} in watch at AppLayout.vue.~

  1. Important! Set startup layout in AppLayout.vue to simple 'div'.
<!-- src/components/AppLayout.vue -->
<template>
    <component :is="layoutName" />
</template>

<script lang="ts">
import { Route } from 'vue-router';

import { defineComponent, ref, watch } from '@vue/composition-api';
import { GetLayoutNameFromRoute } from 'boot/app-layout';

export default defineComponent({

    setup(props, { root })
    {
        const layoutName = ref('div'); // <-- this one to simple div (step 3).

        watch(
            () => root.$route,
            ($route: Route) =>
            {
                const newLayoutName = GetLayoutNameFromRoute($route);

                if (process.env.DEV)
                    console.log(`[AppLayout] Applying '${newLayoutName}' layout.`);

                layoutName.value = newLayoutName;
            },
            {
                // To make it backward-compatible with SSR mode
                immediate: process.env.MODE === 'ssr'
            }
        );

        return { layoutName };
    }
});
</script>

For now it ~works like a charm in SPA mode~ still have double component initialization when switching between different layouts:

  • target route component created (for previous layout).
  • layout changed by 'watch'
  • target route component created again.
Was this page helpful?
0 / 5 - 0 ratings

Related issues

mesqueeb picture mesqueeb  路  3Comments

xereda picture xereda  路  3Comments

alexeigs picture alexeigs  路  3Comments

danikane picture danikane  路  3Comments

fnicollier picture fnicollier  路  3Comments