Skip to content

Instantly share code, notes, and snippets.

@engineersamuel
Last active January 16, 2020 23:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save engineersamuel/858571689dc9af802a26b63b1e8a1b56 to your computer and use it in GitHub Desktop.
Save engineersamuel/858571689dc9af802a26b63b1e8a1b56 to your computer and use it in GitHub Desktop.
Long-term caching GraphQL queries with Webpack

Introduction

When using Apollo Graphql in the front-end, there is no established pattern to separate out your graphql queries into separate files instead of bundled with the js assets. I've searched and tried many various ways, including webpack file-loader and even modifying file-loader. Actually file-loader will separate it out but it returns a url that points to the filesystem which is not easily consumed. The answer is actually strangly obvious once you see the solution.

TL&DR: If you want to skip ahead to the configuration see What worked at the bottom.

The goal being, having some directory structure like:

  • src
    • graphql
      • users.query.graphql
      • orders.query.graphql

That the resulting build has:

  • build/
    • users.query..graphql
    • orders.query..graqphl

Let's take a look at the established pattern first and the result.

Take a look at Loading queries with Webpack which advises the following:

module: {
  rules: [
    {
      test: /\.(graphql|gql)$/,
      exclude: /node_modules/,
      loader: 'graphql-tag/loader',
    }
  ]
}

This is necessary, but this alone will result in all of your graphql queries being included directly in your bundles, like your main.<hash>.graphql. Now this may or may not be desireable. You may want to bundle your graphql queries in your main or split bundles, however, that can't take advantage of long-term caching and HTTP/2. GraphQL queries tend to not change much once they are written and the schema is locked down, but application code does then to change considerably. So it would make sense to separate out the GraphQL queries, but how?

Separating out GraphQL queries

What didn't work

I first though the solution would be as simple as chaining the file-loader before the graphql-tag/loader. This seems logical when looking at it, but it does not work for reasons I'll explain below.

module: {
  rules: [
    {
        test: /\.(graphql|gql)$/,
        exclude: /node_modules/,
        use: [
            'graphql-tag/loader',
            'file-loader?name=[name].[sha512:hash:hex:7].[ext]'
        ]
    }
  ]
}

This would say to load the *.graphql files from the filesystem and maintain them separately, then load the contents (but actually that's not what happens) to the graphql-tag/loader. What I ran into quickly is that file-loader doesn't return the contents but returns a module that contains the path to the file. This led me down to digging into the file-loader to see how it works. The problem quickly became evident, what file-loader outputs is:

export default __webpack_public_path__ path/to/file.graphql

So when graphql-tag/loader receives the content:

module.exports = function(source) {
  this.cacheable();
  const doc = gql`${source}`;

That would translate to: const doc = gql`export default __webpack_public_path path/to/file.graphql`

This will result in the following compilation error in webpack:

ERROR in ./index.tsx
Module build failed (from ../node_modules/graphql-tag/loader.js):
Syntax Error: Unexpected Name "import"

And rightfully so, the source passed to the graphql-tag/loader isn't the actual GraphQL source, it's a module syntax that points to the location of the source.

After trying many combinations of other loads, I finally forked file-loader to add an option forceRawContent that added the following code:

if (options.forceRawContent === true) {
    return source;    
}

While this appeared to work, and the GraphQL files were actually, being emitted due to the previous this.emitFile(outputPath, output); code, the problem is the GraphQL documents were still being included in the js build artifacts. I believe this was due to graphql-tag/loader still being the final loader in the chain, and it has no logic to emit a file. Likely it could be modified to do so, but that extends beyong my Webpack internals knowledge to do at this time. But alas, the solution is even simpler.

What worked

Instead of focusing on the file-loader to emit the .graphql files to the build output, I realized that code-splitting has all that built in. Webpack can already intelligently split any module. The final configuration looks like this:

config.module = {
    rules: [
        // Other loaders ...
        {
            test: /\.(graphql|gql)$/,
            exclude: /node_modules/,
            use: [
                'graphql-tag/loader',
            ]
        }
    ]
};
// ...
config.optimization = {
    minimizer: [
        new TerserPlugin({
            terserOptions: {}
        })
    ],
    // The below is superior for http2
    namedModules: true,
    runtimeChunk: 'single',
    splitChunks: {
        chunks: 'all',
        maxInitialRequests: Infinity,
        minSize: 0,
        cacheGroups: {
            // This configuration forces splitting all graphql files out of modules
            graphql: {
                test: /gql\/queries\/(.*?)\.graphql/,
                chunks: 'all',
                enforce: true,
                priority: 99,
                name(module: any) {
                    // Dir structure is src/gql/queries/*.graphql
                    const packageName = module.resource.match(/gql\/queries\/(.*?)\.graphql/)[1];
                    return `graphql.${packageName}`;
                }
            },
            vendor: {
                test: /[\\/]node_modules[\\/]/,
                reuseExistingChunk: true,
                /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
                name(module: any) {
                    // get the name. E.g. node_modules/packageName/not/this/part.js
                    // or node_modules/packageName
                    const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

                    // npm package names are URL-safe, but some servers don't like @ symbols
                    return `npm.${packageName.replace('@', '')}`;
                }
            },
            // This is very necessary to extract out common components and results is a much smaller final size.
            common: {
                name: 'common',
                minChunks: 2,
                chunks: 'async',
                priority: 10,
                reuseExistingChunk: true,
                enforce: true
            }
        }
    }
};
// ...
config.resolve = {
    modules: [paths.appSrc, 'node_modules'],
    descriptionFiles: ['package.json'],
    extensions: ['.web.js', '.js', '.jsx', '.ts', '.tsx', '.gql', '.graphql']
};

I decided to include the full config.optimizations I have in production right now since there are few more complicated examples out in the wild. The configuration aggresively splits all of my *.graphql files out of all modules into individually separate files. If you removed the priority and enforce and add minChunks then Webpack would 'intelligently' decide how to split out the files based on where those graphql files are referenced in other modules. However, I wanted each and every *.query.graphql, *.mutation.graphql, and *.fragment.graphql to be separated out at build time so they can be long-term cached, hence the above configuration.

published: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment