From feb613ef617cd8282f0b2e9f3deeea5b972e229b Mon Sep 17 00:00:00 2001
From: xaralis <filip.varecha@fragaria.cz>
Date: Sat, 19 Dec 2020 21:22:25 +0100
Subject: [PATCH] feat: avoid losing any intermediate state when WS gets
 disconnected

---
 package-lock.json    |  5 ++++
 package.json         |  3 +-
 src/App.jsx          |  8 ++---
 src/actions/misc.js  |  1 +
 src/actions/ws.js    | 30 +++++++++++++++++++
 src/index.js         | 18 +++++------
 src/ws/connection.js | 71 +++++++++++++++++++++++++++++++-------------
 7 files changed, 97 insertions(+), 39 deletions(-)
 create mode 100644 src/actions/ws.js

diff --git a/package-lock.json b/package-lock.json
index 3d3ff0e..db6b344 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14084,6 +14084,11 @@
         "xml-name-validator": "^3.0.0"
       }
     },
+    "wait-queue": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/wait-queue/-/wait-queue-1.1.4.tgz",
+      "integrity": "sha512-/VdMghiBDG/Ch43ZRp3d8OSd8A0dx8hfkBO7AfWCDzMn2blHquMf+3gqHHhYcggSBpKf7VTzA939bb0DevYKBA=="
+    },
     "walker": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",
diff --git a/package.json b/package.json
index d820587..3b09750 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
     "react-modal": "^3.12.1",
     "react-router-dom": "^5.2.0",
     "react-scripts": "3.4.3",
-    "unfetch": "^4.2.0"
+    "unfetch": "^4.2.0",
+    "wait-queue": "^1.1.4"
   },
   "scripts": {
     "start": "react-scripts start",
diff --git a/src/App.jsx b/src/App.jsx
index d2ba396..88a4cce 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -3,9 +3,7 @@ import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
 import { KeycloakProvider } from "@react-keycloak/web";
 import * as Sentry from "@sentry/react";
 
-import { loadGroupMappings } from "actions/misc";
-import { loadProgram } from "actions/program";
-import { loadPosts } from "actions/posts";
+import { initializeWSChannel } from "actions/ws";
 import Footer from "components/Footer";
 import Navbar from "components/Navbar";
 import Home from "pages/Home";
@@ -57,9 +55,7 @@ const LoadingComponent = (
 );
 
 const BaseApp = () => {
-  loadGroupMappings.read();
-  loadProgram.read();
-  loadPosts.read();
+  initializeWSChannel.read();
 
   return (
     <Router>
diff --git a/src/actions/misc.js b/src/actions/misc.js
index 92ea09c..ca57989 100644
--- a/src/actions/misc.js
+++ b/src/actions/misc.js
@@ -23,3 +23,4 @@ export const loadGroupMappings = createAsyncAction(
     },
   }
 );
+
diff --git a/src/actions/ws.js b/src/actions/ws.js
new file mode 100644
index 0000000..59b5c6e
--- /dev/null
+++ b/src/actions/ws.js
@@ -0,0 +1,30 @@
+import { createAsyncAction, errorResult, successResult } from "pullstate";
+
+import { connect } from "ws/connection";
+
+import { loadPosts } from "./posts";
+import { loadProgram } from "./program";
+
+export const initializeWSChannel = createAsyncAction(async () => {
+  try {
+    const wsChannel = await connect({
+      onConnect: async ({ worker }) => {
+        // Re-load initial data once connected, this will ensure we won't lose
+        // any intermediate state.
+        await Promise.all([
+          loadProgram.run({}, { respectCache: false }),
+          loadPosts.run({}, { respectCache: false }),
+        ]);
+
+        // Once loaded, start processing the messages.
+        worker.start();
+
+        return true;
+      },
+    });
+
+    return successResult(wsChannel);
+  } catch (err) {
+    return errorResult([], err.toString());
+  }
+});
diff --git a/src/index.js b/src/index.js
index 60f1b43..63f1337 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,23 +2,19 @@ import React from "react";
 import ReactDOM from "react-dom";
 import ReactModal from "react-modal";
 
-import { connect } from "./ws/connection";
 import App from "./App";
 import * as serviceWorker from "./serviceWorker";
 
 const root = document.getElementById("root");
 
-const render = () => {
-  ReactDOM.render(
-    <React.StrictMode>
-      <App />
-    </React.StrictMode>,
-    root
-  );
-  ReactModal.setAppElement(root);
-};
+ReactDOM.render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+  root
+);
 
-connect().then(render);
+ReactModal.setAppElement(root);
 
 // If you want your app to work offline and load faster, you can change
 // unregister() to register() below. Note this comes with some pitfalls.
diff --git a/src/ws/connection.js b/src/ws/connection.js
index 5d4ae39..ab2a5f1 100644
--- a/src/ws/connection.js
+++ b/src/ws/connection.js
@@ -1,48 +1,77 @@
+import WaitQueue from "wait-queue";
+
 import { handleRanking } from "./handlers";
 
 const handlerMap = {
   ranked: handleRanking,
 };
 
-const messageRouter = (event) => {
-  console.debug("[ws] New message", event.data);
+function Worker() {
+  const queue = new WaitQueue();
 
-  try {
-    const data = JSON.parse(event.data);
+  const doLoop = async () => {
+    const event = await queue.shift();
+    messageRouter(event);
+    setTimeout(doLoop);
+  };
 
-    if (!data.event) {
-      return console.error("[ws] Missing `event` field");
-    }
+  const messageRouter = (event) => {
+    console.debug("[ws][worker] New message", event.data);
+
+    try {
+      const data = JSON.parse(event.data);
+
+      if (!data.event) {
+        return console.error("[ws][worker] Missing `event` field");
+      }
+
+      const handlerFn = handlerMap[data.event];
 
-    const handlerFn = handlerMap[data.event];
+      if (!handlerFn) {
+        return console.warn(`[ws][worker] Can't handle event '${data.event}'`);
+      }
 
-    if (!handlerFn) {
-      console.warn(`[ws] Can't handle event '${data.event}'`);
+      handlerFn(data.payload || {});
+    } catch (err) {
+      console.error("[ws][worker] Could not parse message as JSON.");
     }
+  };
 
-    handlerFn(data.payload);
-  } catch (err) {
-    console.error("[ws] Could not parse message as JSON.");
-  }
-};
+  return {
+    queue,
+    start: () => {
+      console.debug("[ws][worker] Start processing messages.");
+      doLoop();
+    },
+  };
+}
 
-export const connect = () => {
+export const connect = ({ onConnect }) => {
   return new Promise((resolve, reject) => {
+    const worker = Worker();
+
     const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL);
-    let keepAlive;
+    let keepAliveInterval;
     console.log("[ws] Connecting ...");
 
     ws.onopen = () => {
       console.log("[ws] Connected.");
-      resolve(ws);
 
-      keepAlive = setInterval(() => {
+      keepAliveInterval = setInterval(() => {
         ws.send("KEEPALIVE");
         console.debug("[ws] Sending keepalive.");
       }, 30 * 1000);
+
+      const self = { ws, worker };
+
+      if (onConnect) {
+        return onConnect(self).then(() => resolve(self));
+      }
+
+      return resolve(self);
     };
 
-    ws.onmessage = messageRouter;
+    ws.onmessage = worker.queue.push.bind(worker.queue);
 
     ws.onclose = (event) => {
       console.log(
@@ -50,7 +79,7 @@ export const connect = () => {
         event.reason
       );
 
-      clearInterval(keepAlive);
+      clearInterval(keepAliveInterval);
       setTimeout(connect, 1000);
     };
 
-- 
GitLab