Critical Webpack Resource Injection for Vue 3 SSR Applications

Vue 3 has been officially released for almost a year but presently, at the time of writing this post, its Server Side Rendering (SSR) support is still very lackluster — unless you use an opinionated toolchain like Nuxt 3 (still not released), Quasar 2, or Vite — you're somewhat screwed as a Webpack user.

One important feature back in Vue 2 (but strangely missing in Vue 3) was the ability for the SSR renderer to return a list of critical CSS and JavaScript files needed by all the Vue components rendered on the current page in the SSR context object.

<!-- Vue 2 SSR Template -->

<html>
    <head>
        {{{ context.renderResourceHints() }}}
        {{{ context.renderStyles() }}}
    </head>
    <body>
        {{{ context.renderState() }}}
        {{{ context.renderScripts() }}}
    </body>
</html>

Why is this feature important?

Even without this list of critical CSS, as long as the SSR markup includes the main entry point main.js, then Webpack's client-injected runtime will asynchronously load the missing CSS and JavaScript files for the current page's components.

// Express handler for rendering Vue 3 application

server.get('*', async(req, res) => {
    const app = createSSRApp()
    const appContent = await renderToString(app)
    const html = `
        <html>
        <body>
            <div id="app">${appContent}</div>
            <script src="main.js"></script>
        </body>
        </html>
    `

    res.end(html)
})

Consider the following component hierarchy for a hypothetical Vue 3 SSR website:

If we simply render main.js and let Webpack load the rest, then the user will briefly see a Flash of Unstyled Content (FOUC) because all the HTML was rendered on the server but our components' CSS and JavaScript are not resolved until much later.

Why is this feature missing in Vue 3?

I didn't follow Vue 3's development too closely so I can only speculate based on my experience of reading the source code. Optimistically, I believe it's due to Vue 3 wanting to become toolchain-agnostic. Back in Vue 2, everything was assumed to be bundled with Webpack. In Vue 3, we have Vite and Webpack for bundling Vue applications (I guess Parcel and Gulp also exists but who uses those anymore?). As a result, it would not make sense for Vue 3's SSR renderer (renderToString) to accept a Webpack-specific manifest.json to generate the list of critical resources.

Naive Solution

A naive solution would be to simply preload every CSS and JavaScript file in our Webpack manifest.json file (from webpack-manifest-plugin). However, this is extremely wasteful as it requires our users to load tens of megabytes worth of CSS or JavaScript, many of which they do not even need, and it would likely kill our web performance scores. In addition, we would also get a bunch of warnings in our console.

The resource <URL> was preloaded using link preload but not used within a few seconds from the window's load event.
Please make sure it has an appropriate `as` value and it is preloaded intentionally.

My Webpack 5 Solution

One thing I noticed while playing around with Vue 3 is that we can already determine what components the current route will load without any additional toolchain support.

import { RouteComponent, RouteLocationNormalizedLoaded } from 'vue-router'

export type MatchedComponent = RouteComponent & {
    components?: Record<string, MatchedComponent>
    __file?: string
}

export function getMatchedComponents(route: RouteLocationNormalizedLoaded): Array<MatchedComponent> {
    return route.matched.flatMap((record) => {
        const recordComponents = Object.values(record.components) as Array<MatchedComponent>
        const childComponents = recordComponents.flatMap((c) => c.components ? Object.values(c.components) : []) as Array<MatchedComponent>
        return [
            ...recordComponents,
            ...childComponents,
        ]
    })
}

Unfortunately, this isn't particularly useful because this function only returns a list of components and not their actual output files.

// Output of getMatchedComponents
[
    {
        components: { Header: [Object], Footer: [Object] },
        ssrRender: [Function: ssrRender],
        __file: 'src/web/client/layouts/MainLayout.vue'
    },
    {
        setup: [Function: setup],
        ssrRender: [Function: ssrRender],
        __file: 'src/web/client/layouts/Header.vue'
    },
    {
        setup: [Function: setup],
        ssrRender: [Function: ssrRender],
        __file: 'src/web/client/layouts/Footer.vue'
    },
    {
        setup: [Function: setup],
        ssrRender: [Function: ssrRender],
        __file: 'src/web/client/pages/HomePage.vue'
    }
]

Our goal is to thus map this list of components into their actual output files. One important observation here is that each component object has a __file property. By default, vue-loader only injects this property in development builds. Luckily, we can configure the loader to insert this property in production builds as well by setting exposeFilename: true in our Webpack configuration.

With a unique per-component property, we have a potential manifest lookup key. The simpliest solution would be to have a one-to-one mapping from the Vue file name to the output CSS and JavaScript files. For example, MainLayout.vue could correspond to MainLayout.css and MainLayout.js. If we can force Webpack to output files (also known as "chunks") in this manner, then it would be a trivial task of manipulating each component's __file property to get the output file names.

For large components, this is already Webpack's default behavior: each component's id (inside Webpack) is just their full path with slashes and dots replaced by underscores e.g. src_web_layouts_MainLayout_MainLayout_vue. All we have to do for this case is to pass a function into Webpack's output.filename config to convert this name into MainLayout.css and MainLayout.js.

One optimization Webpack performs is merging multiple small files into a single large file. The problem is that this new file has an incomprehensible name that does not tell us what Vue components are located inside of it. We can resolve this by configuring Webpack's chunking algorithm to split every file regardless of the output file's size so that it never merges any components.

import path from 'path'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import SitemapPlugin from 'sitemap-webpack-plugin'
import { WebpackManifestPlugin } from 'webpack-manifest-plugin'
import { VueLoaderPlugin } from 'vue-loader'
import { createOutputNameFn } from './createOutputNameFn'

export default {
    target: 'web',

    entry: {
        main: path.resolve('src/web/entryClient.ts'),
    },

    module: {
        rules: [
            {
                test: /\.vue$/,
                use: [{
                    loader: 'vue-loader',
                    options: {
                        // Sets __file property in ComponentOptions in production
                        exposeFilename: true,
                    },
                }],
            },
        ]
    },

    optimization: {
        // Do not mangle names in production
        chunkIds: 'named',

        // Do not merge chunks so we can output each component to named files
        // e.g. HomePage.vue -> HomePage.js HomePage.css
        splitChunks: {
            chunks: 'all',
            minSize: 0,
        },
    },

    output: {
        filename: createOutputNameFn('js', true),
        chunkFilename: createOutputNameFn('js', false),
    },

    plugins: [
        new VueLoaderPlugin(),
        new MiniCssExtractPlugin({
            filename: createOutputNameFn('css', true),
            chunkFilename: createOutputNameFn('css', false),
        }),
        new WebpackManifestPlugin({
            fileName: 'ssr-manifest.json',
        }),
    ]
}
import { Chunk } from 'webpack'
import { isDev } from './webpack.common'

let chunkNameCounter = 0
const chunkNameMap = new Map<string, number>()

export function createOutputNameFn(ext: string, isInitial: boolean): (pathData: unknown) => string {
    const suffix = isDev
        ? ext
        : `[contenthash].${ext}`

    return (pathData): string => {
        const data = pathData as { chunk: Chunk }
        const chunkId = String(data.chunk.id)

        // Only emit initial vendors file as 'vendor.js'
        if (chunkId.startsWith('vendors') && isInitial) {
            return `vendors.${suffix}`
        }

        if (chunkId.endsWith('_vue')) {
            const pathParts = chunkId.split('_').reverse()
            const fileName = (pathParts[1] === 'index')
                ? pathParts[2]
                : pathParts[1]

            return `${fileName}.${suffix}`
        }

        let id = isDev
            ? '[name]'
            : chunkNameMap.get(chunkId)

        if (id === undefined) {
            chunkNameCounter += 1
            id = chunkNameCounter
            chunkNameMap.set(chunkId, id)
        }

        return `${id}.${suffix}`
    }
}

Finally putting everything together in our HTTP handler:

server.get('*', async(req, res) => {
    const renderer = new VueSsrRenderer('ssr-manifest.json')
    const { app, router } = createApp()
    const routeComponents = getMatchedComponents(router.currentRoute.value)
    const html = await renderer.render(app, appContext, routeComponents)
    res.end(html)
})
import { SSRContext } from '@vue/server-renderer'
import { readFileSync } from 'fs'
import { App } from 'vue'
import { getFileName } from './getFileName'
import { MatchedComponent } from './getMatchedComponents'

export class VueSsrRenderer<AppContext extends SSRContext> {
    private _manifest: Map<string, string>

    constructor(manifestPath: string) {
        const rawManifest = JSON.parse(readFileSync(manifestPath).toString('utf-8')) as Record<string, string>
        this._manifest = new Map(Object.entries(rawManifest))
    }

    async render(app: App, appContext: AppContext, routeComponents: Array<MatchedComponent>): Promise<string> {
        const { renderToString } = await import('@vue/server-renderer')
        const headLinks = this.renderHeadLinks(routeComponents)
        const appHtml = await renderToString(app, appContext)

        return `
            <!DOCTYPE html ${appContext.teleports?.htmlAttrs ?? ''}>
            <html lang="en">
            <head ${appContext.teleports?.headAttrs ?? ''}>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                ${headLinks}
                ${appContext.teleports?.head ?? ''}
            </head>
            <body ${appContext.teleports?.bodyAttrs ?? ''}>
                <div id="app">${appHtml}</div>
                ${appContext.teleports?.body ?? ''}
                ${this.renderScript('vendors.js')}
                ${this.renderScript('main.js')}
            </body>
            </html>
        `
    }

    private renderHeadLinks(routeComponents: Array<MatchedComponent>): string {
        let head = ''

        head += this.renderCss('vendors.css')
        head += this.renderPreloadLink('vendors.js')

        head += this.renderCss('main.css')
        head += this.renderPreloadLink('main.js')

        // Try to see if any of the matched components in our route exists in the manifest
        // If it exists, then insert a preload script for performance and avoid FOUC
        for (const c of routeComponents) {
            const componentName = c.__file
                ? getFileName(c.__file)
                : c.name

            if (!componentName) {
                continue
            }

            head += this.renderPreloadLink(`${componentName}.js`)
            head += this.renderCss(`${componentName}.css`)
        }

        return head
    }

    private renderPreloadLink(fileName: string): string {
        const filePath = this._manifest.get(fileName)

        if (filePath?.endsWith('.js')) {
            return `<link rel="preload" href="${filePath}" as="script">\n`
        } else if (filePath?.endsWith('.css')) {
            return `<link rel="preload" href="${filePath}" as="style">\n`
        }

        return ''
    }

    private renderCss(fileName: string): string {
        const filePath = this._manifest.get(fileName)
        if (!filePath) {
            return ''
        }

        return `<link rel="stylesheet" href="${filePath}">\n`
    }

    private renderScript(fileName: string): string {
        const filePath = this._manifest.get(fileName)
        if (!filePath) {
            return ''
        }

        return `<script src="${filePath}" defer></script>\n`
    }
}

Limitations

  • Your component file names must be globally unique. For example, you cannot have multiple components named Header.vue in different directories since createOutputNameFn only returns the file name.
  • You are exposing your repository directory structure to your users. If we were to open our bundled JavaScript files, we can clearly see our source directory paths in the source.

    Obviously this isn't a problem if your source code is already open source. For closed-source projects, this is something to consider but it shouldn't be an issue as long as your directory or file names are not confidential or politically-sensitive.

  • Your users will be loading a separate CSS and JavaScript file for each individual component. However, I think the upside of avoiding FOUC outweighs the performance penalty of a few extra network connections.
  • getMatchedComponents cannot determine which asynchronous components are loaded. As a result, even though they are still be rendered on the server by renderToString, their CSS and JavaScript will not be loaded until after they are resolved on the frontend which will result in a FOUC. There's no way around this other than avoiding asynchronous components altogether (i.e. do not use defineAsyncComponent in SSR applications).
  • Finally, you can only have one entry point in your Webpack config.

    export default {
        target: 'web',
    
        entry: {
            main: path.resolve('src/web/entryClient.ts'), // Only 1 entry allowed
        },
    }
    

    If you try to add another entry point (e.g. for a service worker), Webpack would combine any shared vendor code between all your entry points into a new vendor file. As a result, createOutputNameFn would try to output multiple initial vendor files with the same name thus throwing an exception.

    This shouldn't be a big issue since most projects should only need one entry point per configuration. If your SSR application does have multiple entry points, you would have to instead duplicate your configuration and output each entry point to a different directory.