Repository: ProjectEvergreen/greenwood (js)

Issue:
similar query calls breaking production build with Invariant Violation 8 error
## Type of Change
- [ ] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [x] Bug
- [ ] Other (please clarify below)

## Summary
In my [website project](https://github.com/thegreenhouseio/www.thegreenhouse.io/pull/137), I have a [page template](https://github.com/thegreenhouseio/www.thegreenhouse.io/blob/technical/issue-133-markdown-for-blog-posts-bak/src/templates/post-template.js) template that is making its own `query` call.
```js
import { html, LitElement } from 'lit-element';
import client from '@greenwood/cli/data/client';
import gql from 'graphql-tag';

import '../components/blog-post/blog-post';
import '../components/footer/footer';
import '../components/header/header';
import '../components/navigation/navigation';
import pageCss from '../styles/page.css';
import '../styles/theme.css';

MDIMPORT;

class PostTemplate extends LitElement {
  ...

  async connectedCallback() {
    super.connectedCallback();
    let route = window.location.pathname;
    const response = await client.query({
      query: gql`query {
        graph {
          title,
          link,
          data {
            date,
            image
          }
        }
      }`
    });

    ...
  }

  ...
}

customElements.define('page-template', PostTemplate);
```

Results in an ApolloClient error
```
index.c10096742db3b816782b.bundle.js:1 Uncaught (in promise) Invariant Violation: Invariant Violation: 8 (see https://github.com/apollographql/invariant-packages)
    at new t (http://localhost:8000/index.c10096742db3b816782b.bundle.js:1:17113)
    at l (http://localhost:8000/index.c10096742db3b816782b.bundle.js:1:17334)
    at R (http://localhost:8000/index.c10096742db3b816782b.bundle.js:1:10323)
    at L (http://localhost:8000/index.c10096742db3b816782b.bundle.js:1:13986)
    at t.transformDocument (http://localhost:8000/index.c10096742db3b816782b.bundle.js:98:67607)
    at t.read (http://localhost:8000/index.c10096742db3b816782b.bundle.js:98:65761)
    at t.e.readQuery (http://localhost:8000/index.c10096742db3b816782b.bundle.js:98:45766)
    at http://localhost:8000/index.c10096742db3b816782b.bundle.js:98:72246
```

![Screen Shot 2020-06-16 at 8 38 43 PM](https://user-images.githubusercontent.com/895923/84841805-6560d980-b011-11ea-88a9-8f87ea5307f7.png)


## Details
What I think correlation here is that since app-template is also making a `query` call for the graph, and this page template an adapted version of it, when logging _cache.json_, it looks like there are _two_ entries for the graph, one per call.  
![Screen Shot 2020-06-16 at 8 40 56 PM](https://user-images.githubusercontent.com/895923/84841905-ba9ceb00-b011-11ea-8ce2-ce7e2b653c4f.png)


Since it only happens in production, this is likely an issue with our [mock Apollo client](https://spectrum.chat/apollo/apollo-client/optimistic-response-failed-with-invariant-violation-8~9995b7e0-4cdd-42d1-9a50-2b3983079d2d).

Pull request:
Bug/issue 371 similar query calls invariant error
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #371 

## Summary of Changes
> _For context, my thoughts on root cause analysis can be found [here](https://github.com/ProjectEvergreen/greenwood/issues/371#issuecomment-654492993)_
1. Created a common utility that will generate a unique hash per query, that can in turn be used to write / read cache file paths in _cache.js_ and _client.js_, respectively
1. Added unit tests and test cases, observed #386 that may result in intermittent test failures on this PR perhaps
1. Removed _cache.json_, left `window.__APOLLO_STATE__`, cleaned up deps.  
    - This effectively invalidates #349 at this point, IMO
    - Would also take care of #317 too then?

~~**Note**: You can test on the home page now and see multiple `query` calls now returning.   However, do keep in mind that pages with a menu will break right now until I add support for variables.  (right now both `menu` queries are using the same keys and so filenames are not unique)~~
<img width="1652" alt="Screen Shot 2020-07-08 at 10 21 37 PM" src="https://user-images.githubusercontent.com/895923/86989906-6cd75680-c169-11ea-9d9b-1eed04eb3dcf.png">

## TODOs
1. [x] Add support for variables (to fix pages with `menu` queries)
1. [x] Add a test case
1. [x] remove `deepmerge` dependency / unified _cache.json_ in **serialize** lifecycle.  All _-cache.json_ files can now stay at the root as well
1. [x] return an actual hash
1. [x] Perf comparison - need to swap out with something other than `crypto`, much too large!
1. [x] remove `console` logs / example in _home-template.js_

Patch:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e1cf473c..3c46b9db 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -44,12 +44,10 @@
     "css-loader": "^2.1.1",
     "css-to-string-loader": "^0.1.3",
     "cssnano": "^4.1.10",
-    "deepmerge": "^4.2.2",
     "file-loader": "^3.0.1",
     "filewatcher-webpack-plugin": "^1.2.0",
     "front-matter": "^3.0.1",
     "fs-extra": "^8.1.0",
-    "glob-promise": "^3.4.0",
     "graphql": "^14.5.8",
     "graphql-tag": "^2.10.1",
     "html-webpack-plugin": "^3.2.0",
@@ -74,6 +72,7 @@
     "webpack-merge": "^4.2.1"
   },
   "devDependencies": {
+    "glob-promise": "^3.4.0",
     "rehype-autolink-headings": "^4.0.0",
     "rehype-slug": "^3.0.0"
   }
diff --git a/packages/cli/src/data/cache.js b/packages/cli/src/data/cache.js
index 758a1201..6690b2f3 100644
--- a/packages/cli/src/data/cache.js
+++ b/packages/cli/src/data/cache.js
@@ -1,11 +1,11 @@
 const { ApolloClient } = require('apollo-client');
 const createHttpLink = require('apollo-link-http').createHttpLink;
-const crypto = require('crypto');
 const fetch = require('node-fetch');
 const fs = require('fs-extra');
 const { gql } = require('apollo-server');
 const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache;
 const path = require('path');
+const { getQueryHash } = require('./common');
 
 /* Extract cache server-side */
 module.exports = async (req, context) => {
@@ -22,26 +22,25 @@ module.exports = async (req, context) => {
 
       /* Take the same query from request, and repeat the query for our server side cache */
       const { query, variables } = req.body;
+      const queryObj = gql`${query}`;
 
       const { data } = await client.query({
-        query: gql`${query}`,
+        query: queryObj,
         variables
       });
 
       if (data) {
         const cache = JSON.stringify(client.extract());
-        const md5 = crypto.createHash('md5').update(query + JSON.stringify(variables)).digest('hex');
-
-        /* Get the requests entire (full) route and rootRoute to use as reference for designated cache directory */
-        const { origin, referer } = req.headers;
-        const fullRoute = referer.substring(origin.length, referer.length);
-        const rootRoute = fullRoute.substring(0, fullRoute.substring(1, fullRoute.length).indexOf('/') + 1);
-        const targetDir = path.join(context.publicDir, rootRoute);
-        const targetFile = path.join(targetDir, `${md5}-cache.json`);
+        const queryHash = getQueryHash(queryObj, variables);
+        const hashFilename = `${queryHash}-cache.json`;
+        const cachePath = `${context.publicDir}/${queryHash}-cache.json`;
+        
+        if (!fs.existsSync(context.publicDir)) {
+          fs.mkdirSync(context.publicDir);
+        }
 
-        if (!fs.existsSync(targetFile)) {
-          fs.mkdirsSync(targetDir, { recursive: true });
-          fs.writeFileSync(path.join(targetFile), cache, 'utf8');
+        if (!fs.existsSync(cachePath)) {
+          fs.writeFileSync(path.join(context.publicDir, hashFilename), cache, 'utf8');
         }
       }
       resolve();
diff --git a/packages/cli/src/data/client.js b/packages/cli/src/data/client.js
index 1f80e120..f5d99297 100644
--- a/packages/cli/src/data/client.js
+++ b/packages/cli/src/data/client.js
@@ -1,10 +1,11 @@
 import { ApolloClient } from 'apollo-client';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 import { HttpLink } from 'apollo-link-http';
+import { getQueryHash } from '@greenwood/cli/data/common';
 
 const APOLLO_STATE = window.__APOLLO_STATE__; // eslint-disable-line no-underscore-dangle
 const client = new ApolloClient({
-  cache: new InMemoryCache().restore(APOLLO_STATE),
+  cache: new InMemoryCache(),
   link: new HttpLink({
     uri: 'http://localhost:4000'
   })
@@ -12,15 +13,12 @@ const client = new ApolloClient({
 const backupQuery = client.query;
 
 client.query = (params) => {
-
   if (APOLLO_STATE) {
     // __APOLLO_STATE__ defined, in "SSG" mode...
-    const root = window.location.pathname.split('/')[1];
-    const rootSuffix = root === ''
-      ? ''
-      : '/';
-
-    return fetch(`/${root}${rootSuffix}cache.json`)
+    const queryHash = getQueryHash(params.query, params.variables);
+    const cachePath = `/${queryHash}-cache.json`;
+    
+    return fetch(cachePath)
       .then(response => response.json())
       .then((response) => {
         // mock client.query response
diff --git a/packages/cli/src/data/common.js b/packages/cli/src/data/common.js
new file mode 100644
index 00000000..278a7370
--- /dev/null
+++ b/packages/cli/src/data/common.js
@@ -0,0 +1,46 @@
+// https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0#gistcomment-2775538
+function hashString(queryKeysString) {
+  let h = 0;
+
+  for (let i = 0; i < queryKeysString.length; i += 1) {
+    h = Math.imul(31, h) + queryKeysString.charCodeAt(i) | 0; // eslint-disable-line no-bitwise
+  }
+
+  return Math.abs(h).toString();
+}
+
+function getQueryKeysFromSelectionSet(selectionSet) {
+  let queryKeys = '';
+
+  for (let key in selectionSet) {
+    
+    if (key === 'selections') {
+      queryKeys += selectionSet[key]
+        .filter(selection => selection.name.value !== '__typename') // __typename is added by server.js
+        .map(selection => selection.name.value).join('');
+    }
+  }
+  
+  if (selectionSet.kind === 'SelectionSet') {
+    selectionSet.selections.forEach(selection => {
+      if (selection.selectionSet) {
+        queryKeys += getQueryKeysFromSelectionSet(selection.selectionSet);
+      }
+    });
+  }
+
+  return queryKeys;
+}
+
+function getQueryHash(query, variables = {}) {
+  const queryKeys = getQueryKeysFromSelectionSet(query.definitions[0].selectionSet);
+  const variableValues = Object.keys(variables).length > 0
+    ? `_${Object.values(variables).join('').replace(/\//g, '')}` // handle / which will translate to filepaths
+    : '';
+
+  return hashString(`${queryKeys}${variableValues}`);
+}
+
+module.exports = {
+  getQueryHash
+};
\ No newline at end of file
diff --git a/packages/cli/src/lifecycles/serialize.js b/packages/cli/src/lifecycles/serialize.js
index 6f645cc5..52c4ff22 100644
--- a/packages/cli/src/lifecycles/serialize.js
+++ b/packages/cli/src/lifecycles/serialize.js
@@ -1,34 +1,9 @@
 const BrowserRunner = require('../lib/browser');
 const dataServer = require('../data/server');
-const deepmerge = require('deepmerge');
 const fs = require('fs-extra');
-const glob = require('glob-promise');
 const LocalWebServer = require('local-web-server');
 const path = require('path');
 
-const setDataForPages = async (context) => {
-  const { publicDir } = context;
-  const pages = await glob.promise(path.join(publicDir, '**/**/index.html'));
-
-  pages.forEach((pagePath) => {
-    const contents = fs.readFileSync(pagePath, 'utf-8');
-    const pageRoot = pagePath.replace(publicDir, '').split('/')[1];
-    const cacheRoot = pageRoot === 'index.html'
-      ? ''
-      : `${pageRoot}`;
-    let cacheContents = {};
-    
-    glob.sync(`${publicDir}/${cacheRoot}/*-cache.json`).forEach((file) => {
-      cacheContents = deepmerge(cacheContents, require(file));
-    });
-
-    const serialzedCacheContents = JSON.stringify(cacheContents);
-
-    fs.writeFileSync(`${publicDir}/${cacheRoot}/cache.json`, serialzedCacheContents);
-    fs.writeFileSync(pagePath, contents.replace('___DATA___', serialzedCacheContents));
-  });
-};
-
 module.exports = serializeBuild = async (compilation) => {
   const browserRunner = new BrowserRunner();
   const localWebServer = new LocalWebServer();
@@ -50,7 +25,7 @@ module.exports = serializeBuild = async (compilation) => {
             .replace(polyfill, '')
             .replace('<script></script>', `
               <script data-state="apollo">
-                window.__APOLLO_STATE__=___DATA___;
+                window.__APOLLO_STATE__ = true;
               </script> 
             `);
   
@@ -93,9 +68,6 @@ module.exports = serializeBuild = async (compilation) => {
       browserRunner.close();
       webServer.close();
 
-      // loop through all index.html files and inject cache
-      await setDataForPages(compilation.context);
-
       resolve();
     } catch (err) {
       reject(err);
diff --git a/yarn.lock b/yarn.lock
index 1fbe8804..374430eb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4632,11 +4632,6 @@ deepmerge@4.0.0:
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
   integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
 
-deepmerge@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
-  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
-
 default-gateway@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b"


Test patch:
diff --git a/packages/cli/test/cases/build.data.graph-custom-frontmatter/build.data.graph-custom-frontmatter.spec.js b/packages/cli/test/cases/build.data.graph-custom-frontmatter/build.data.graph-custom-frontmatter.spec.js
index 9114f311..d4cb63f9 100644
--- a/packages/cli/test/cases/build.data.graph-custom-frontmatter/build.data.graph-custom-frontmatter.spec.js
+++ b/packages/cli/test/cases/build.data.graph-custom-frontmatter/build.data.graph-custom-frontmatter.spec.js
@@ -29,7 +29,7 @@ const TestBed = require('../../../../../test/test-bed');
 
 describe('Build Greenwood With: ', function() {
   const LABEL = 'Data from GraphQL and using Custom Frontmatter Data';
-  const apolloStateRegex = /window.__APOLLO_STATE__=({.*?});/;
+  const apolloStateRegex = /window.__APOLLO_STATE__ = true/;
   let setup;
 
   before(async function() {
@@ -54,14 +54,18 @@ describe('Build Greenwood With: ', function() {
         expect(fs.existsSync(path.join(this.context.publicDir, 'blog', 'first-post', 'index.html'))).to.be.true;
       });
 
-      it('should output one cache.json file', async function() {
-        expect(await glob.promise(path.join(this.context.publicDir, 'blog', 'cache.json'))).to.have.lengthOf(1);
+      it('should output a (partial) *-cache.json file, one per each query made', async function() {
+        expect(await glob.promise(path.join(this.context.publicDir, './*-cache.json'))).to.have.lengthOf(3);
       });
 
-      it('should output one cache.json file to be defined', function() {
-        const cacheContents = require(path.join(this.context.publicDir, 'blog', 'cache.json'));
+      it('should output a (partial) *-cache.json files, one per each query made, that are all defined', async function() {
+        const cacheFiles = await glob.promise(path.join(this.context.publicDir, './*-cache.json'));
 
-        expect(cacheContents).to.not.be.undefined;
+        cacheFiles.forEach(file => {
+          const cache = require(file);
+
+          expect(cache).to.not.be.undefined;
+        });
       });
 
       it('should have one window.__APOLLO_STATE__ <script> with (approximated) expected state', () => {
diff --git a/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js b/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js
index c6cbcf50..f9801d9c 100644
--- a/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js
+++ b/packages/cli/test/cases/build.data.graph/build.data.graph.spec.js
@@ -1,6 +1,7 @@
 /*
  * Use Case
- * Run Greenwood build command with GraphQL calls to get data about the projects graph.
+ * Run Greenwood build command with GraphQL calls to get data about the projects graph.  This will test various 
+ * permutations of Children, Menu, and Graph calls, simulating a site of blog posts.
  *
  * User Result
  * Should generate a Greenwood build that dynamically serializes data from the graph from the header and in the page-template.
@@ -16,12 +17,15 @@
  *     header.js
  *   pages/
  *     blog/
- *       first-post.md
- *       second-post.md
+ *       first-post/
+ *         index.md
+ *       second-post/
+ *         index.md
  *     index.md
  *   templates/
  *     app-template.js
  *     blog-template.js
+ *     post-template.js
  */
 const expect = require('chai').expect;
 const fs = require('fs');
@@ -32,7 +36,7 @@ const TestBed = require('../../../../../test/test-bed');
 
 describe('Build Greenwood With: ', function() {
   const LABEL = 'Data from GraphQL';
-  const apolloStateRegex = /window.__APOLLO_STATE__=({.*?});/;
+  const apolloStateRegex = /window.__APOLLO_STATE__ = true/;
   let setup;
 
   before(async function() {
@@ -69,21 +73,11 @@ describe('Build Greenwood With: ', function() {
         expect(await glob.promise(path.join(this.context.publicDir, './index.*.bundle.js'))).to.have.lengthOf(1);
       });
 
-      it('should output one (unified) cache.json file', async function() {
-        expect(await glob.promise(path.join(this.context.publicDir, './cache.json'))).to.have.lengthOf(1);
+      it('should output a (partial) *-cache.json file, one per each query made', async function() {
+        expect(await glob.promise(path.join(this.context.publicDir, './*-cache.json'))).to.have.lengthOf(5);
       });
 
-      it('should output one (unified) cache.json file that is defined', function() {
-        const cacheContents = require(path.join(this.context.publicDir, 'cache.json'));
-
-        expect(cacheContents).to.not.be.undefined;
-      });
-
-      it('should output three (partial) *-cache.json files, one per query made', async function() {
-        expect(await glob.promise(path.join(this.context.publicDir, './*-cache.json'))).to.have.lengthOf(3);
-      });
-
-      it('should output three (partial) *-cache.json files that are all defined', async function() {
+      it('should output a (partial) *-cache.json files, one per each query made, that are all defined', async function() {
         const cacheFiles = await glob.promise(path.join(this.context.publicDir, './*-cache.json'));
 
         cacheFiles.forEach(file => {
@@ -112,46 +106,28 @@ describe('Build Greenwood With: ', function() {
 
       it('should have a expected NavigationQuery output in the <header> tag', function() {
         const listItems = dom.window.document.querySelectorAll('body header ul li');
-        const link = listItems[0].querySelector('a');
+        const link1 = listItems[0].querySelector('a');
+        const link2 = listItems[1].querySelector('a');
 
         expect(listItems.length).to.be.equal(2);
-        expect(link.href.replace('file://', '')).to.be.equal('/blog/first-post');
-        expect(link.title).to.be.equal('Click to visit the First blog post');
-        expect(link.innerHTML).to.contain('First');
+        
+        expect(link1.href.replace('file://', '')).to.be.equal('/blog/first-post/');
+        expect(link1.title).to.be.equal('Click to visit the First blog post');
+        expect(link1.innerHTML).to.contain('First');
+
+        expect(link2.href.replace('file://', '')).to.be.equal('/blog/second-post/');
+        expect(link2.title).to.be.equal('Click to visit the Second blog post');
+        expect(link2.innerHTML).to.contain('Second');
       });
     });
 
     describe('Blog Page (Template) w/ Navigation and Children Query', function() {
       beforeEach(async function() {
-        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'blog', 'first-post', 'index.html'));
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'blog', 'index.html'));
       });
 
       it('should output an index.html file (first post page)', function() {
-        expect(fs.existsSync(path.join(this.context.publicDir, 'blog', 'first-post', 'index.html'))).to.be.true;
-      });
-
-      it('should output one (unified) cache.json file', async function() {
-        expect(await glob.promise(path.join(this.context.publicDir, 'blog', 'cache.json'))).to.have.lengthOf(1);
-      });
-
-      it('should output one (unified) cache.json file that is defined', function() {
-        const cacheContents = require(path.join(this.context.publicDir, 'blog', 'cache.json'));
-
-        expect(cacheContents).to.not.be.undefined;
-      });
-
-      it('should output four ("partial") *-cache.json files, one per query made', async function() {
-        expect(await glob.promise(path.join(this.context.publicDir, 'blog', './*-cache.json'))).to.have.lengthOf(4);
-      });
-
-      it('should output four (partial) *-cache.json files that are defined', async function() {
-        const cacheFiles = await glob.promise(path.join(this.context.publicDir, 'blog', './*-cache.json'));
-
-        cacheFiles.forEach(file => {
-          const cache = require(file);
-
-          expect(cache).to.not.be.undefined;
-        });
+        expect(fs.existsSync(path.join(this.context.publicDir, 'blog', 'index.html'))).to.be.true;
       });
 
       it('should have one window.__APOLLO_STATE__ <script> with (approximated) expected state', () => {
@@ -172,12 +148,18 @@ describe('Build Greenwood With: ', function() {
 
       it('should have expected navigation links in the <header> tag tag when using NavigationQuery', function() {
         const listItems = dom.window.document.querySelectorAll('body header ul li');
-        const link = listItems[0].querySelector('a');
+        const link1 = listItems[0].querySelector('a');
+        const link2 = listItems[1].querySelector('a');
 
         expect(listItems.length).to.be.equal(2);
-        expect(link.href.replace('file://', '')).to.be.equal('/blog/first-post');
-        expect(link.title).to.be.equal('Click to visit the First blog post');
-        expect(link.innerHTML).to.contain('First');
+        
+        expect(link1.href.replace('file://', '')).to.be.equal('/blog/first-post/');
+        expect(link1.title).to.be.equal('Click to visit the First blog post');
+        expect(link1.innerHTML).to.contain('First');
+
+        expect(link2.href.replace('file://', '')).to.be.equal('/blog/second-post/');
+        expect(link2.title).to.be.equal('Click to visit the Second blog post');
+        expect(link2.innerHTML).to.contain('Second');
       });
 
       it('should have expected blog posts links in the <body> tag when using ChildrenQuery', function() {
@@ -200,6 +182,60 @@ describe('Build Greenwood With: ', function() {
       });
     });
 
+    describe('Blog Post Pages (Post Template) w/ custom Graph Query', function() {
+      let dom2;
+
+      // just use one post page for the generic tests here
+      beforeEach(async function() {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'blog', 'first-post', 'index.html'));
+        dom2 = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'blog', 'second-post', 'index.html'));
+      });
+
+      it('should output an index.html file for first blog post page', function() {
+        expect(fs.existsSync(path.join(this.context.publicDir, 'blog', 'first-post', 'index.html'))).to.be.true;
+      });
+
+      it('should have one window.__APOLLO_STATE__ <script> with (approximated) expected state', () => {
+        const scriptTags = dom.window.document.querySelectorAll('head > script');
+        const apolloScriptTags = Array.prototype.slice.call(scriptTags).filter(script => {
+          return script.getAttribute('data-state') === 'apollo';
+        });
+
+        expect(apolloScriptTags.length).to.be.equal(1);
+        expect(apolloScriptTags[0].innerHTML).to.match(apolloStateRegex);
+      });
+
+      it('should have a <header> tag in the <body>', function() {
+        const header = dom.window.document.querySelectorAll('body header');
+
+        expect(header.length).to.be.equal(1);
+      });
+
+      it('should have expected navigation links in the <header> tag tag when using NavigationQuery', function() {
+        const listItems = dom.window.document.querySelectorAll('body header ul li');
+        const link = listItems[0].querySelector('a');
+
+        expect(listItems.length).to.be.equal(2);
+        expect(link.href.replace('file://', '')).to.be.equal('/blog/first-post/');
+        expect(link.title).to.be.equal('Click to visit the First blog post');
+        expect(link.innerHTML).to.contain('First');
+      });
+
+      it('should have expected date in the <body> tag when using custom GraphQuery', function() {
+        const date = dom.window.document.querySelectorAll('body p.date');
+
+        expect(date.length).to.be.equal(1);
+        expect(date[0].innerHTML).to.contain('Posted on 07.08.2020');
+      });
+
+      it('should have expected date in the <body> tag when using custom GraphQuery', function() {
+        const date = dom2.window.document.querySelectorAll('body p.date');
+
+        expect(date.length).to.be.equal(1);
+        expect(date[0].innerHTML).to.contain('Posted on 07.09.2020');
+      });
+    });
+
   });
 
   after(function() {
diff --git a/packages/cli/test/cases/build.data.graph/src/pages/blog/first-post.md b/packages/cli/test/cases/build.data.graph/src/pages/blog/first-post/index.md
similarity index 62%
rename from packages/cli/test/cases/build.data.graph/src/pages/blog/first-post.md
rename to packages/cli/test/cases/build.data.graph/src/pages/blog/first-post/index.md
index 7be6e086..259b5451 100644
--- a/packages/cli/test/cases/build.data.graph/src/pages/blog/first-post.md
+++ b/packages/cli/test/cases/build.data.graph/src/pages/blog/first-post/index.md
@@ -1,7 +1,8 @@
 ---
-template: 'blog'
+template: 'post'
 title: 'First'
 menu: navigation
+date: '07.08.2020'
 ---
 
 ## My First Blog Post
diff --git a/packages/cli/test/cases/build.data.graph/src/pages/blog/index.md b/packages/cli/test/cases/build.data.graph/src/pages/blog/index.md
new file mode 100644
index 00000000..3d2da3d2
--- /dev/null
+++ b/packages/cli/test/cases/build.data.graph/src/pages/blog/index.md
@@ -0,0 +1,5 @@
+---
+template: 'blog'
+---
+
+## My Posts
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.data.graph/src/pages/blog/second-post.md b/packages/cli/test/cases/build.data.graph/src/pages/blog/second-post/index.md
similarity index 63%
rename from packages/cli/test/cases/build.data.graph/src/pages/blog/second-post.md
rename to packages/cli/test/cases/build.data.graph/src/pages/blog/second-post/index.md
index 77c833fb..6cb0aff7 100644
--- a/packages/cli/test/cases/build.data.graph/src/pages/blog/second-post.md
+++ b/packages/cli/test/cases/build.data.graph/src/pages/blog/second-post/index.md
@@ -1,7 +1,8 @@
 ---
-template: 'blog'
+template: 'post'
 title: 'Second'
 menu: navigation
+date: '07.09.2020'
 ---
 
 ## My Second Blog Post
diff --git a/packages/cli/test/cases/build.data.graph/src/templates/blog-template.js b/packages/cli/test/cases/build.data.graph/src/templates/blog-template.js
index ef42d041..50f08f75 100644
--- a/packages/cli/test/cases/build.data.graph/src/templates/blog-template.js
+++ b/packages/cli/test/cases/build.data.graph/src/templates/blog-template.js
@@ -29,7 +29,9 @@ class BlogTemplate extends LitElement {
       }
     });
 
-    this.posts = response.data.children;
+    this.posts = response.data.children.filter(post => {
+      return post.filePath.indexOf('/blog/index') < 0;
+    });
   }
 
   /* eslint-disable indent */
@@ -41,16 +43,14 @@ class BlogTemplate extends LitElement {
       <div class='container'>
         <app-header></app-header>
 
-        <entry></entry>
-
         <div class="posts">
-          <h1>More Posts</h1>
-
+          <entry></entry>
+          
           <ul>
-            ${posts.map((item) => {
+            ${posts.map((post) => {
               return html`
                 <li>
-                  <a href="${item.link}/" title="Click to read my ${item.title} blog post">${item.title}</a>
+                  <a href="${post.link}" title="Click to read my ${post.title} blog post">${post.title}</a>
                 </li>
               `;
             })}
diff --git a/packages/cli/test/cases/build.data.graph/src/templates/post-template.js b/packages/cli/test/cases/build.data.graph/src/templates/post-template.js
new file mode 100644
index 00000000..a47886df
--- /dev/null
+++ b/packages/cli/test/cases/build.data.graph/src/templates/post-template.js
@@ -0,0 +1,69 @@
+import client from '@greenwood/cli/data/client';
+import gql from 'graphql-tag';
+import { html, LitElement } from 'lit-element';
+import '../components/header';
+
+MDIMPORT;
+
+class PostTemplate extends LitElement {
+
+  static get properties() {
+    return {
+      post: {
+        type: Object
+      }
+    };
+  }
+
+  constructor() {
+    super();
+
+    this.post = {
+      title: '',
+      link: '',
+      data: {
+        date: ''
+      }
+    };
+  }
+
+  async connectedCallback() {
+    super.connectedCallback();
+    let route = window.location.pathname;
+    const response = await client.query({
+      query: gql`query {
+        graph {
+          title,
+          link,
+          data {
+            date
+          }
+        }
+      }`
+    });
+
+    if (route.lastIndexOf('/') !== route.length - 1) {
+      route = `${route}/`;
+    }
+
+    this.post = response.data.graph.filter((page) => {
+      return page.link.lastIndexOf(route) >= 0;
+    })[0];
+  }
+
+  render() {
+    const { date } = this.post.data;
+
+    return html`
+      <div>
+        <app-header></app-header>
+        
+        <entry></entry>
+
+        <p class="date">Posted on ${date}</p>
+      </div>
+    `;
+  }
+}
+
+customElements.define('page-template', PostTemplate);
\ No newline at end of file
diff --git a/packages/cli/test/unit/data/common.spec.js b/packages/cli/test/unit/data/common.spec.js
new file mode 100644
index 00000000..c8d7e836
--- /dev/null
+++ b/packages/cli/test/unit/data/common.spec.js
@@ -0,0 +1,71 @@
+const expect = require('chai').expect;
+const { gql } = require('apollo-server');
+const { getQueryHash } = require('../../../src/data/common');
+
+describe('Unit Test: Data', function() {
+
+  describe('Common', function() {
+
+    describe('getQueryHash', function() {
+      
+      it('should return the expected hash for a standard graph query', function () {
+        // __typename is added by server.js
+        const query = gql`
+          query {
+            graph {
+              id,
+              title,
+              link,
+              filePath,
+              fileName,
+              template,
+              __typename
+            }
+          }
+        `;
+        const hash = getQueryHash(query);
+
+        expect(hash).to.be.equal('876029931');
+      });
+
+      it('should return the expected hash for a custom graph query with custom data', function () {
+        const query = gql`
+          query {
+            graph {
+              title,
+              link,
+              data {
+                date,
+                image
+              }
+            }
+          }
+        `;
+        const hash = getQueryHash(query);
+
+        expect(hash).to.be.equal('1656784831');
+      });
+
+      it('should return the expected hash for a children query with a variable', function () {
+        const query = gql`
+          query($parent: String!) {
+            children(parent: $parent) {
+              id,
+              title,
+              link,
+              filePath,
+              fileName,
+              template
+            }
+          }
+        `;
+        const hash = getQueryHash(query, {
+          parent: '/docs/'
+        });
+
+        expect(hash).to.be.equal('1366211136');
+      });
+    });
+
+  });
+});
\ No newline at end of file