Critical Webpack Resource Injection for Vue 3 SSR Applications

Update: Apr 28, 2022
I've created a separate Webpack plugin to generate the manifest of critical assets without any of the limitations from my original solution below. It consists of a client plugin and server plugin similar to how SSR worked in Vue 2.

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.

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.

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

Component Hierarchy of a Possible 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.

Initial page load where only vendor.css is loaded (CSS from node_modules such as UI libraries, hence the styled buttons)
After MainLayout.vue's CSS is loaded
After HomePage.vue's CSS is loaded

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.

My Hacky 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.

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

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.

Finally putting everything together in our HTTP handler:

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.

    If we inspect the JavaScript bundle of one of my SSR websites HoloMemes, we can clearly see my project's directory structure

    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.

    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.