Repository: kiwicom/mobile
Pull request #1190 (2018-10-05 07:06:59)

Relay: refactor environment to use async/await


Patch:
diff --git a/packages/localization/src/DefaultVocabulary.js b/packages/localization/src/DefaultVocabulary.js
index bf497d96..f1f9b04f 100644
--- a/packages/localization/src/DefaultVocabulary.js
+++ b/packages/localization/src/DefaultVocabulary.js
@@ -40,6 +40,8 @@ const CoreVocabulary = {
 const SharedVocabulary = {
   'relay.query_renderer.no_connection':
     'No internet connection, please check your internet settings or try it later.',
+  'relay.query_renderer.timeout':
+    'The response from the server timed out. Please try again.',
 
   'shared.age_picker.done': 'Done',
   'shared.bar_popup.cancel': 'Cancel',
diff --git a/packages/relay/index.js b/packages/relay/index.js
index 053f3bf7..c9cbf104 100644
--- a/packages/relay/index.js
+++ b/packages/relay/index.js
@@ -31,7 +31,7 @@ export const commitMutation = (
 ) => {
   if (ConnectionManager.isConnected() === false) {
     Alert.translatedAlert(null, { id: 'relay.query_renderer.no_connection' });
-    config.onCompleted(null, 'No connection');
+    config.onCompleted({ data: null }, 'No connection');
     return;
   }
   if (token) {
diff --git a/packages/relay/src/Environment.js b/packages/relay/src/Environment.js
index 770ebcc7..13f04ce7 100644
--- a/packages/relay/src/Environment.js
+++ b/packages/relay/src/Environment.js
@@ -23,34 +23,88 @@ const GRAPHQL_URL = 'https://graphql.kiwi.com/';
 
 const store = new Store(new RecordSource());
 
-function fetchFromTheNetwork(networkHeaders, operation, variables, observer) {
-  fetch(GRAPHQL_URL, {
-    method: 'POST',
-    headers: networkHeaders,
-    body: JSON.stringify({
-      query: operation.text, // TODO: fetch persisted queries instead (based on operation.id)
-      variables,
+const fetchWithTimeout = (url, config) =>
+  Promise.race([
+    fetch(url, config),
+    new Promise((resolve, reject) => {
+      setTimeout(() => {
+        reject({ message: 'Timeout' });
+      }, 15000);
     }),
-  })
-    .then(fetchResponse => fetchResponse.json())
-    .then(jsonResponse => {
-      if (jsonResponse.errors) {
-        jsonResponse.errors.forEach(error => console.warn(error));
-      }
-      observer.next(jsonResponse);
-      observer.complete();
-    })
-    .then(() => {
-      AsyncStorage.setItem(
-        ASYNC_STORE_KEY,
-        JSON.stringify(store.getSource()),
-      ).catch(error => {
-        // AsyncStorage write wasn't successful - nevermind
-        console.warn(error);
-      });
+  ]);
+
+async function fetchFromTheNetwork(
+  networkHeaders,
+  operation,
+  variables,
+  observer,
+) {
+  try {
+    const fetchResponse = await fetchWithTimeout(GRAPHQL_URL, {
+      method: 'POST',
+      headers: networkHeaders,
+      body: JSON.stringify({
+        query: operation.text, // TODO: fetch persisted queries instead (based on operation.id)
+        variables,
+      }),
     });
+
+    const jsonResponse = await fetchResponse.json();
+    if (jsonResponse.errors) {
+      jsonResponse.errors.forEach(error => console.warn(error));
+    }
+
+    observer.next(jsonResponse);
+    observer.complete();
+  } catch (error) {
+    // Handle error from fetch, unless we will see loader forever
+    observer.error(error);
+    observer.complete();
+    console.warn(error);
+  }
+  AsyncStorage.setItem(ASYNC_STORE_KEY, JSON.stringify(store.getSource()));
 }
 
+const handleNoNetworkNoCachedResponse = observer => {
+  // If not we are stuck on loader forever.
+  observer.error({ message: 'No network' });
+  observer.complete();
+};
+
+const asyncStoreRead = async (observer, operation, variables) => {
+  try {
+    const content = await AsyncStorage.getItem(ASYNC_STORE_KEY);
+    if (content === null) {
+      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);
+  }
+};
+
 export default function createEnvironment(accessToken: string = '') {
   const networkHeaders: Object = {
     Accept: 'application/json',
@@ -62,59 +116,15 @@ export default function createEnvironment(accessToken: string = '') {
     networkHeaders.authorization = accessToken;
   }
 
-  const fetchQuery = (
-    operation,
-    variables,
-    cacheConfig,
-  ): Promise<FetcherResponse> => {
+  const fetchQuery = (operation, variables): Promise<FetcherResponse> => {
     return Observable.create(observer => {
-      if (cacheConfig.force === true && ConnectionManager.isConnected()) {
-        return fetchFromTheNetwork(
-          networkHeaders,
-          operation,
-          variables,
-          observer,
-        );
-      }
-
-      if (!ConnectionManager.isConnected()) {
+      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
-        AsyncStorage.getItem(ASYNC_STORE_KEY)
-          .then(content => {
-            if (content !== null) {
-              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();
-              }
-            }
-          })
-          .catch(error => {
-            // AsyncStorage read wasn't successful - nevermind
-            console.warn(error);
-          });
-      } else {
-        fetchFromTheNetwork(networkHeaders, operation, variables, observer);
+        asyncStoreRead(observer, operation, variables);
       }
     });
   };
diff --git a/packages/relay/src/QueryRenderer.js b/packages/relay/src/QueryRenderer.js
index 1e930048..aa6ea30b 100644
--- a/packages/relay/src/QueryRenderer.js
+++ b/packages/relay/src/QueryRenderer.js
@@ -19,6 +19,11 @@ type RendererResponse = {|
   +props?: Object,
 |};
 
+const noConnectionErrorMessages = [
+  'Network request failed', // See: https://github.com/github/fetch/blob/fcc4e1b48cfb5a2b1625fcd6eac06d954b00ccb6/fetch.js#L438-L444
+  'No network',
+];
+
 export default class QueryRenderer extends React.Component<Props> {
   createEnvironment = () => {
     const accessToken = this.props.accessToken;
@@ -30,18 +35,17 @@ export default class QueryRenderer extends React.Component<Props> {
   };
 
   renderRelayContainer = ({ error, props }: RendererResponse) => {
-    if (error) {
-      if (error.message === 'Network request failed') {
-        // See: https://github.com/github/fetch/blob/fcc4e1b48cfb5a2b1625fcd6eac06d954b00ccb6/fetch.js#L438-L444
-        return (
-          <GeneralError
-            errorMessage={
-              <Translation id="relay.query_renderer.no_connection" />
-            }
-          />
-        );
-      }
-
+    if (error && noConnectionErrorMessages.includes(error.message)) {
+      return (
+        <GeneralError
+          errorMessage={<Translation id="relay.query_renderer.no_connection" />}
+        />
+      );
+    } else if (error && error.message === 'Timeout') {
+      <GeneralError
+        errorMessage={<Translation id="relay.query_renderer.timeout" />}
+      />;
+    } else if (error) {
       // total failure (data == null, errors != null)
       return (
         <GeneralError


Test patch:
diff --git a/packages/relay/__tests__/index.test.js b/packages/relay/__tests__/index.test.js
index b6937837..0f1a40ae 100644
--- a/packages/relay/__tests__/index.test.js
+++ b/packages/relay/__tests__/index.test.js
@@ -45,7 +45,10 @@ describe('commitMutation', () => {
   it('does not call network if there is no connection', () => {
     commitMutation(config);
     expect(Relay.commitMutation).not.toHaveBeenCalled();
-    expect(config.onCompleted).toHaveBeenCalledWith(null, 'No connection');
+    expect(config.onCompleted).toHaveBeenCalledWith(
+      { data: null },
+      'No connection',
+    );
   });
 
   it('does calls commit mutation and PrivateEnvironment if there is connection and a token', () => {