Repository: kiwicom/mobile
Pull request #1352 (2018-11-22 16:05:03)

Relay: Persist relay cache manager to async storage


Patch:
diff --git a/packages/relay/src/CacheManager.js b/packages/relay/src/CacheManager.js
new file mode 100644
index 00000000..fb9a85ef
--- /dev/null
+++ b/packages/relay/src/CacheManager.js
@@ -0,0 +1,91 @@
+// @flow
+
+/* eslint-disable no-underscore-dangle */
+
+import { AsyncStorage } from 'react-native';
+import { QueryResponseCache } from 'relay-runtime';
+
+const CACHE_KEY = '@CACHE_MANAGER:';
+const CACHE_DURATION: number = 1000 * 60 * 60 * 24 * 7; // one week
+const CACHE_SIZE = 50;
+
+type RelayCacheManager = {|
+  +_responses: Map<
+    string,
+    {|
+      +payload: Object,
+      +fetchTime: Date,
+    |},
+  >,
+  +get: (queryId: string, variables: Object) => void,
+  +set: (queryId: string, variables: Object, payload: any) => void,
+|};
+
+/**
+ * This is a wrapper around relay cache manager, storing to async storage
+ * and reading data on startup
+ */
+export default new class CacheManager {
+  cache: RelayCacheManager;
+
+  constructor() {
+    this.cache = new QueryResponseCache({
+      size: CACHE_SIZE,
+      ttl: CACHE_DURATION,
+    });
+    this.restoreCache();
+  }
+
+  get = (queryId: string, variables: Object) =>
+    this.cache.get(queryId, variables);
+
+  set = (queryId: string, variables: Object, payload: any) => {
+    this.cache.set(queryId, variables, payload);
+    this.persistCache();
+  };
+
+  // Do this async so we don't block the UI
+  persistCache = async () => {
+    const keyValuePairs = [];
+    this.cache._responses.forEach((value, key) => {
+      keyValuePairs.push([`${CACHE_KEY}${key}`, JSON.stringify(value)]);
+    });
+    AsyncStorage.multiSet(keyValuePairs);
+  };
+
+  restoreCache = async () => {
+    let cacheSize = 0;
+    const keys = await AsyncStorage.getAllKeys();
+    const cachedData = await AsyncStorage.multiGet(
+      keys.filter(key => key.startsWith(CACHE_KEY)),
+    );
+    const keysToDelete = [];
+    cachedData.forEach(([key, data]) => {
+      const parsedData = JSON.parse(data);
+
+      if (parsedData == null) {
+        keysToDelete.push(key);
+        return;
+      }
+
+      const { queryID, variables } = JSON.parse(
+        key.substring(CACHE_KEY.length),
+      );
+
+      if (this.isCurrent(parsedData.fetchTime) && cacheSize < CACHE_SIZE) {
+        this.cache.set(queryID, variables, parsedData.payload);
+        cacheSize++;
+      } else {
+        keysToDelete.push(key);
+      }
+    });
+    AsyncStorage.multiRemove(keysToDelete);
+  };
+
+  isCurrent(fetchTime: Date) {
+    return (
+      new Date(fetchTime).getTime() + new Date(CACHE_DURATION).getTime() >=
+      Date.now()
+    );
+  }
+}();
diff --git a/packages/relay/src/Environment.js b/packages/relay/src/Environment.js
index 7f8214d8..d51b1f13 100644
--- a/packages/relay/src/Environment.js
+++ b/packages/relay/src/Environment.js
@@ -7,19 +7,17 @@ import {
   RecordSource,
   Store,
   Observable,
-  createOperationSelector,
 } from 'relay-runtime';
-import { AsyncStorage } from 'react-native';
 import fetchWithRetries from '@mrtnzlml/fetch';
 
 import ConnectionManager from './ConnectionManager';
+import CacheManager from './CacheManager';
 
 type FetcherResponse = {|
   +data: Object,
   +errors?: $ReadOnlyArray<Object>,
 |};
 
-const ASYNC_STORE_KEY = '@OfflineStore:key';
 const GRAPHQL_URL = 'https://graphql.kiwi.com/';
 
 const store = new Store(new RecordSource());
@@ -46,6 +44,9 @@ async function fetchFromTheNetwork(
     if (jsonResponse.errors) {
       jsonResponse.errors.forEach(error => console.warn(error));
     }
+    if (operation.operationKind !== 'mutation') {
+      CacheManager.set(operation.name, variables, jsonResponse);
+    }
 
     observer.next(jsonResponse);
     observer.complete();
@@ -55,7 +56,6 @@ async function fetchFromTheNetwork(
     observer.complete();
     console.warn(error);
   }
-  AsyncStorage.setItem(ASYNC_STORE_KEY, JSON.stringify(store.getSource()));
 }
 
 const handleNoNetworkNoCachedResponse = observer => {
@@ -66,32 +66,20 @@ const handleNoNetworkNoCachedResponse = observer => {
 
 const asyncStoreRead = async (observer, operation, variables) => {
   try {
-    const content = await AsyncStorage.getItem(ASYNC_STORE_KEY);
-    if (content === null) {
+    const cachedData = CacheManager.get(operation.name, variables);
+
+    if (cachedData) {
+      // It loads smoother if we do this in a set timeout
+      // If we don't the UI freezes for a while
+      setTimeout(() => {
+        observer.next(cachedData);
+        if (ConnectionManager.isConnected === false) {
+          observer.complete();
+        }
+      });
+    } else if (ConnectionManager.isConnected() === false) {
       return handleNoNetworkNoCachedResponse(observer);
     }
-    store.publish(new RecordSource(JSON.parse(content)));
-    const operationSelector = createOperationSelector(operation, variables);
-
-    if (store.check(operationSelector.root)) {
-      // This seems to work, but if we discover some problems with it
-      // Maybe we should rather return here
-      // I posted a question about it here: https://discordapp.com/channels/102860784329052160/102861057189490688
-      const root = {
-        ...operationSelector.root,
-        node: {
-          ...operationSelector.root.node,
-          selections: operationSelector.root.node.selections.filter(
-            selection => selection.kind !== 'LinkedHandle',
-          ),
-        },
-      };
-
-      observer.next(store.lookup(root));
-      observer.complete();
-    } else {
-      handleNoNetworkNoCachedResponse(observer);
-    }
   } catch (error) {
     //   // AsyncStorage read wasn't successful - nevermind
     console.warn('error', error);
@@ -111,13 +99,11 @@ export default function createEnvironment(accessToken: string = '') {
 
   const fetchQuery = (operation, variables): Promise<FetcherResponse> => {
     return Observable.create(observer => {
+      if (operation.operationKind !== 'mutation') {
+        asyncStoreRead(observer, operation, variables);
+      }
       if (ConnectionManager.isConnected()) {
         fetchFromTheNetwork(networkHeaders, operation, variables, observer);
-      } else if (operation.operationKind !== 'mutation') {
-        // We have some issue with the store and connections, it causes the app to crash because of some race condition.
-        // For now let's just use this appreach if we are offline
-        // try to read content from the cache and use it if possible
-        asyncStoreRead(observer, operation, variables);
       }
     });
   };


Test patch:
diff --git a/packages/relay/src/__tests__/CacheManager.test.js b/packages/relay/src/__tests__/CacheManager.test.js
new file mode 100644
index 00000000..5d2762fd
--- /dev/null
+++ b/packages/relay/src/__tests__/CacheManager.test.js
@@ -0,0 +1,78 @@
+// @flow
+
+import { AsyncStorage } from 'react-native';
+import MockDate from 'mockdate';
+
+import CacheManager from '../CacheManager';
+
+MockDate.set(new Date(2017, 1, 1));
+
+const first = {
+  queryID: '123',
+  variables: { a: '', b: 2 },
+  fetchTime: new Date(2018, 0, 1),
+  payload: { lol: 'lol' },
+};
+
+beforeEach(() => {
+  jest.mock('AsyncStorage', () => ({
+    multiGet: jest.fn(() => [
+      [
+        `@CACHE_MANAGER:${JSON.stringify({
+          queryID: '123',
+          variables: { a: '', b: 2 },
+        })}`,
+        JSON.stringify({
+          fetchTime: new Date(2018, 0, 1),
+          payload: { lol: 'lol' },
+        }),
+      ],
+      [
+        `@CACHE_MANAGER:${JSON.stringify({
+          queryID: '1234',
+          variables: { a: 'a', b: 22 },
+        })}`,
+        JSON.stringify({
+          fetchTime: new Date(2016, 0, 1),
+          payload: { lol: 'lollern' },
+        }),
+      ],
+    ]),
+    multiSet: jest.fn(),
+    multiRemove: jest.fn(),
+    getAllKeys: jest.fn(async () => [
+      `@CACHE_MANAGER:${JSON.stringify({
+        queryID: '123',
+        variables: { a: '', b: 2 },
+      })}`,
+      `@CACHE_MANAGER:${JSON.stringify({
+        queryID: '1234',
+        variables: { a: 'a', b: 22 },
+      })}`,
+    ]),
+  }));
+});
+
+describe('CacheManager', () => {
+  it('initialises', () => {
+    expect(CacheManager.cache).toBeDefined();
+  });
+
+  it('restores cache', async () => {
+    const spy = jest.spyOn(CacheManager.cache, 'set');
+
+    await CacheManager.restoreCache();
+    expect(spy).toHaveBeenCalledWith(
+      first.queryID,
+      first.variables,
+      first.payload,
+    );
+
+    expect(AsyncStorage.multiRemove).toHaveBeenCalledWith([
+      `@CACHE_MANAGER:${JSON.stringify({
+        queryID: '1234',
+        variables: { a: 'a', b: 22 },
+      })}`,
+    ]);
+  });
+});