node.jstypescriptserver-side-renderingvuejs3vue-cli-4

Vue.js 3 SSR - missing template or render function during client hydration


I'm trying to create Vue.js 3 SSR app (including ts, @vue/cli, babel). I'm using nodejs + express as a backend. SSR is working fine (I'm getting properly rendered html from server), but error occurs during client side hydration. It seems like my client build does not include component's template, because I'm getting these errors in browser

Vue warn]: Component is missing template or render function. 
  at <App> 
  at <App>

Vue warn]: Hydration node mismatch:
- Client vnode: Symbol(Comment) 
- Server rendered DOM: <div class=​"hello-world">​hello​</div>​  
  at <App> 
  at <App>

runtime-core.cjs.js:2942 Hydration completed but contains mismatches.

I found out that error disappeared when I replace template by render function, but it occurs at child component again (for simplicity, HelloWorld does not have child component).
Here is my github repo where I reproduced my problem.

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
  </head>
  <body>
    <div id="app">SSR_APP_CONTENT</div>
  </body>
</html>

package.json

{
    "name": "ssr3",
    "version": "0.1.0",
    "private": true,
    "scripts": {
        "serve": "vue-cli-service serve",
        "build": "rimraf dist && yarn build:server && yarn build:client",
        "build:server": "cross-env SSR=true vue-cli-service build --dest dist/server --mode development",
        "build:client": "vue-cli-service build --dest dist/client --mode development",
        "lint": "vue-cli-service lint"
    },
    "dependencies": {
        "core-js": "^3.6.5",
        "vue": "^3.0.0",
        "vue-router": "^4.0.0-0",
        "@vue/server-renderer": "^3.0.0",
        "class-transformer": "^0.3.1",
        "express": "^4.17.1",
        "reflect-metadata": "^0.1.13",
        "webpack-manifest-plugin": "^2.2.0",
        "webpack-node-externals": "^2.5.2"
    },
    "devDependencies": {
        "@typescript-eslint/eslint-plugin": "^2.33.0",
        "@typescript-eslint/parser": "^2.33.0",
        "@vue/cli-plugin-babel": "~4.5.0",
        "@vue/cli-plugin-eslint": "~4.5.0",
        "@vue/cli-plugin-router": "~4.5.0",
        "@vue/cli-plugin-typescript": "~4.5.0",
        "@vue/cli-service": "~4.5.0",
        "@vue/compiler-sfc": "^3.0.0",
        "@vue/eslint-config-prettier": "^6.0.0",
        "@vue/eslint-config-typescript": "^5.0.2",
        "eslint": "^6.7.2",
        "eslint-plugin-prettier": "^3.1.3",
        "eslint-plugin-vue": "^7.0.0-0",
        "node-sass": "^4.12.0",
        "prettier": "^1.19.1",
        "sass-loader": "^8.0.2",
        "typescript": "~3.9.3",
        "cross-env": "^7.0.2",
        "husky": "^4.2.5",
        "lint-staged": "^10.2.11"
    },
    "eslintConfig": {
        "root": true,
        "env": {
            "node": true
        },
        "extends": [
            "plugin:vue/vue3-essential",
            "eslint:recommended",
            "@vue/typescript/recommended",
            "@vue/prettier",
            "@vue/prettier/@typescript-eslint"
        ],
        "parserOptions": {
            "ecmaVersion": 2020
        },
        "rules": {}
    },
    "prettier": {
        "semi": false,
        "singleQuote": true,
        "tabWidth": 4,
        "arrowParens": "avoid",
        "printWidth": 100
    },
    "browserslist": [
        "> 1%",
        "last 2 versions",
        "not dead"
    ]
}

vue.config.js

const ManifestPlugin = require('webpack-manifest-plugin')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack')

module.exports = {
    configureWebpack: {
        resolve: { mainFields: ['main', 'module'] }
    },

    chainWebpack: webpackConfig => {
        const isSSR = process.env.SSR
        webpackConfig
            .entry('app')
            .clear()
            .add(isSSR ? './src/entry-server.ts' : './src/entry-client.ts')

        webpackConfig.plugin('manifest').use(new ManifestPlugin({ fileName: 'manifest.json' }))

        if (!isSSR) {
            return
        }

        webpackConfig.target('node')
        webpackConfig.output.libraryTarget('commonjs2')

        webpackConfig.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }))

        webpackConfig.optimization.splitChunks(false).minimize(false)

        webpackConfig.plugins.delete('hmr')
        webpackConfig.plugins.delete('preload')
        webpackConfig.plugins.delete('prefetch')
        webpackConfig.plugins.delete('progress')
        webpackConfig.plugin('limit').use(
            new webpack.optimize.LimitChunkCountPlugin({
                maxChunks: 1
            })
        )
    }
}

App.vue

<template>
    <HelloWorld />
</template>

<script lang="ts">
    import { defineComponent } from 'vue'
    import HelloWorld from '@/components/HelloWorld.vue'

    export default defineComponent({
        components: { HelloWorld }
    })
</script>

HelloWorld.vue

<template>
    <div class="hello-world">hello</div>
</template>

<script lang="ts">
    import { defineComponent } from 'vue'
    export default defineComponent({})
</script>

app.ts

import { createSSRApp, h } from 'vue'
import App from '@/App.vue'

export const createApp = () => {
    const rootComponent = {
        render: () => h(App),
        components: { App }
    }
    const app = createSSRApp(rootComponent)
    return { app }
}

entry-server.ts

import { createApp } from '@/app'

export default async () => {
    const { app } = createApp()
    return app
}

entry-client.ts

import { createApp } from '@/app.ts'

const { app } = createApp()
app.mount('#app', true)

server.js

const path = require('path')
const fs = require('fs')
const express = require('express')
const { renderToString } = require('@vue/server-renderer')
const serverManifest = require('./dist/server/manifest.json')

const server = express()

const appPath = path.join(__dirname, '/dist/server', serverManifest['app.js'])
const createApp = require(appPath).default

server.use('/img', express.static(path.join(__dirname, '/dist/client', 'img')))
server.use('/js', express.static(path.join(__dirname, '/dist/client', 'js')))
server.use('/css', express.static(path.join(__dirname, '/dist/client', 'css')))

server.get(['/*'], async (req, res) => {
    const app = await createApp()
    const appContent = await renderToString(app)

    fs.readFile(path.join(__dirname, '/dist/client/index.html'), (err, html) => {
        if (err) {
            throw err
        }

        html = html.toString().replace('SSR_APP_CONTENT', `${appContent}`)
        res.setHeader('Content-Type', 'text/html')
        res.send(html)
    })
})

server.listen(80)

My setup

- macOS Catalina 10.15.5
- Nodejs v12.19.0
- Google Chrome v86.0.4240.111

Solution

  • I have finally found out that cache-loader was causing my issue.
    By running yarn build:server && yarn build:client, the client build was using cached components from the server build, then there was no render function, because ssr build produces ssrRender function only.
    I've fixed it by disabling cache-loader at chainWebpack function in vue.config.js.

    chainWebpack: webpackConfig => {
        ...
        webpackConfig.module.rule('vue').uses.delete('cache-loader')
        webpackConfig.module.rule('js').uses.delete('cache-loader')
        webpackConfig.module.rule('ts').uses.delete('cache-loader')
        ...
    }
    

    ref: https://forum.vuejs.org/t/disable-cache-loader-in-webpack-4-vue-cli-3/57561