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
- 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?
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.
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.