Repository: Avaiga/taipy
Pull request: GUI: Decouple ws synchro update (#854)
Resolve #854

Problem statement:
Enhance decouple websocket synchro
**Description**
Decouple API should handled reload cases and recoginize data tree changes when reload

**Acceptance Criteria**
- [ ] Ensure new code is unit tested, and check code coverage is at least 90%
- [ ] Propagate any change on the demos and run all of them to ensure there is no breaking change
- [ ] Ensure any change is well documented

Hints from issue discussion:

Commit message: Updated websocket waterfall

Patch to review:
diff --git a/frontend/taipy-gui/base/src/app.ts b/frontend/taipy-gui/base/src/app.ts
index 0a788d1a24..b7f5478d7d 100644
--- a/frontend/taipy-gui/base/src/app.ts
+++ b/frontend/taipy-gui/base/src/app.ts
@@ -1,4 +1,5 @@
-import { sendWsMessage } from "../../src/context/wsUtils";
+import { getLocalStorageValue } from "../../src/context/utils";
+import { sendWsMessage, TAIPY_CLIENT_ID } from "../../src/context/wsUtils";
 import { uploadFile } from "../../src/workers/fileupload";
 
 import { Socket, io } from "socket.io-client";
@@ -13,6 +14,7 @@ export class TaipyApp {
     _onInit: OnInitHandler | undefined;
     _onChange: OnChangeHandler | undefined;
     variableManager: VariableManager | undefined;
+    appId: string;
     clientId: string;
     context: string;
     path: string | undefined;
@@ -29,6 +31,7 @@ export class TaipyApp {
         this.variableManager = undefined;
         this.clientId = "";
         this.context = "";
+        this.appId = "";
         this.path = path;
         this.socket = socket;
         initSocket(socket, this);
@@ -55,6 +58,20 @@ export class TaipyApp {
         this._onChange = handler;
     }
 
+    // Utility methods
+    init() {
+        this.clientId = "";
+        this.context = "";
+        this.appId = "";
+        const id = getLocalStorageValue(TAIPY_CLIENT_ID, "");
+        sendWsMessage(this.socket, "ID", TAIPY_CLIENT_ID, id, id, undefined, false);
+        sendWsMessage(this.socket, "AID", "connect", "", id, undefined, false);
+        if (id !== "") {
+            this.clientId = id;
+            this.updateContext(this.path);
+        }
+    }
+
     // Public methods
     getEncodedName(varName: string, module: string) {
         return this.variableManager?.getEncodedName(varName, module);
@@ -64,8 +81,8 @@ export class TaipyApp {
         return this.variableManager?.getName(encodedName);
     }
 
-    get(varName: string) {
-        return this.variableManager?.get(varName);
+    get(encodedName: string) {
+        return this.variableManager?.get(encodedName);
     }
 
     getInfo(encodedName: string) {
diff --git a/frontend/taipy-gui/base/src/utils.ts b/frontend/taipy-gui/base/src/utils.ts
index 6c2c6296fd..020f63e501 100644
--- a/frontend/taipy-gui/base/src/utils.ts
+++ b/frontend/taipy-gui/base/src/utils.ts
@@ -1,6 +1,6 @@
 import { Socket } from "socket.io-client";
-import { IdMessage, getLocalStorageValue, storeClientId } from "../../src/context/utils";
-import { TAIPY_CLIENT_ID, WsMessage, sendWsMessage } from "../../src/context/wsUtils";
+import { IdMessage, storeClientId } from "../../src/context/utils";
+import { WsMessage, sendWsMessage } from "../../src/context/wsUtils";
 import { TaipyApp } from "./app";
 import { VariableManager, VariableModuleData } from "./variableManager";
 
@@ -11,13 +11,14 @@ interface MultipleUpdatePayload {
 
 export const initSocket = (socket: Socket, appManager: TaipyApp) => {
     socket.on("connect", () => {
-        const id = getLocalStorageValue(TAIPY_CLIENT_ID, "");
-        sendWsMessage(socket, "ID", TAIPY_CLIENT_ID, id, id, undefined, false);
-        if (id !== "") {
-            appManager.clientId = id;
-            appManager.updateContext(appManager.path);
+        if (appManager.clientId === "" || appManager.appId === "") {
+            appManager.init();
         }
     });
+    // Send a request to get App ID to verify that the app has not been reloaded
+    socket.io.on("reconnect", () => {
+        sendWsMessage(socket, "AID", "reconnect", appManager.appId, appManager.clientId, appManager.context);
+    });
     // try to reconnect on connect_error
     socket.on("connect_error", () => {
         setTimeout(() => {
@@ -32,7 +33,7 @@ export const initSocket = (socket: Socket, appManager: TaipyApp) => {
     });
     // handle message data from backend
     socket.on("message", (message: WsMessage) => {
-        processIncomingMessage(message, appManager);
+        processWsMessage(message, appManager);
     });
     // only now does the socket tries to open/connect
     if (!socket.connected) {
@@ -40,17 +41,17 @@ export const initSocket = (socket: Socket, appManager: TaipyApp) => {
     }
 };
 
-export const processIncomingMessage = (message: WsMessage, appManager: TaipyApp) => {
+const processWsMessage = (message: WsMessage, appManager: TaipyApp) => {
     if (message.type) {
         if (message.type === "MU" && Array.isArray(message.payload)) {
             for (const muPayload of message.payload as [MultipleUpdatePayload]) {
                 const encodedName = muPayload.name;
-                const value = muPayload.payload.value;
+                const { value } = muPayload.payload;
                 appManager.variableManager?.update(encodedName, value);
                 appManager.onChange && appManager.onChange(appManager, encodedName, value);
             }
         } else if (message.type === "ID") {
-            const id = (message as unknown as IdMessage).id;
+            const { id } = message as unknown as IdMessage;
             storeClientId(id);
             appManager.clientId = id;
             appManager.updateContext(appManager.path);
@@ -58,15 +59,33 @@ export const processIncomingMessage = (message: WsMessage, appManager: TaipyApp)
             const mc = (message.payload as Record<string, unknown>).data as string;
             window.localStorage.setItem("ModuleContext", mc);
             appManager.context = mc;
-            sendWsMessage(appManager.socket, "GVS", "get_variables", {}, appManager.clientId, appManager.context);
         } else if (message.type === "GVS") {
             const variableData = (message.payload as Record<string, unknown>).data as VariableModuleData;
             if (appManager.variableManager) {
-                appManager.variableManager.resetInitData(variableData);
+                appManager.variableManager.init(variableData);
             } else {
                 appManager.variableManager = new VariableManager(variableData);
                 appManager.onInit && appManager.onInit(appManager);
             }
+        } else if (message.type === "AID") {
+            const payload = message.payload as Record<string, unknown>;
+            if (payload.name === "reconnect") {
+                return appManager.init();
+            }
+            appManager.appId = payload.id as string;
         }
+        postWsMessageProcessing(message, appManager);
+    }
+};
+
+const postWsMessageProcessing = (message: WsMessage, appManager: TaipyApp) => {
+    // perform data population only when all necessary metadata is ready
+    if (
+        ["ID", "AID", "GMC"].includes(message.type) &&
+        appManager.clientId !== "" &&
+        appManager.appId !== "" &&
+        appManager.context !== ""
+    ) {
+        sendWsMessage(appManager.socket, "GVS", "get_variables", {}, appManager.clientId, appManager.context);
     }
 };
diff --git a/frontend/taipy-gui/base/src/variableManager.ts b/frontend/taipy-gui/base/src/variableManager.ts
index cc502c4d57..5f1611c6ab 100644
--- a/frontend/taipy-gui/base/src/variableManager.ts
+++ b/frontend/taipy-gui/base/src/variableManager.ts
@@ -22,10 +22,30 @@ export class VariableManager {
     constructor(variableModuleData: VariableModuleData) {
         this._data = {};
         this._variables = {};
-        this.resetInitData(variableModuleData);
+        this.init(variableModuleData);
     }
 
-    resetInitData(variableModuleData: VariableModuleData) {
+    init(variableModuleData: VariableModuleData) {
+        // Identify changes between the new and old data
+        const changes: VariableModuleData = {};
+        for (const context in this._variables) {
+            if (!(context in variableModuleData)) {
+                changes[context] = this._variables[context];
+                continue;
+            }
+            for (const variable in this._variables[context]) {
+                if (!(variable in variableModuleData[context])) {
+                    if (!(context in changes)) {
+                        changes[context] = {};
+                    }
+                    changes[context][variable] = this._variables[context][variable];
+                }
+            }
+        }
+        if (changes) {
+            console.error("Unmatched data tree! Removed changes: ", changes);
+        }
+        // Reset the initial data
         this._variables = variableModuleData;
         this._data = {};
         for (const context in this._variables) {
diff --git a/frontend/taipy-gui/base/webpack.config.js b/frontend/taipy-gui/base/webpack.config.js
index 9fdfb85974..51c0a65535 100644
--- a/frontend/taipy-gui/base/webpack.config.js
+++ b/frontend/taipy-gui/base/webpack.config.js
@@ -1,14 +1,17 @@
 const path = require("path");
 const webpack = require("webpack");
+const resolveApp = relativePath => path.resolve(__dirname, relativePath);
 
 const moduleName = "TaipyGuiBase";
+const basePath = "../../../taipy/gui/webapp";
+const webAppPath = resolveApp(basePath);
 
 module.exports = {
     target: "web",
     entry: "./base/src/index.ts",
     output: {
         filename: "taipy-gui-base.js",
-        path: path.resolve(__dirname, "dist"),
+        path: webAppPath,
         globalObject: "this",
         library: {
             name: moduleName,
diff --git a/frontend/taipy-gui/src/context/wsUtils.ts b/frontend/taipy-gui/src/context/wsUtils.ts
index 55e8d57578..5acea736d1 100644
--- a/frontend/taipy-gui/src/context/wsUtils.ts
+++ b/frontend/taipy-gui/src/context/wsUtils.ts
@@ -3,7 +3,23 @@ import { v4 as uuidv4 } from "uuid";
 
 export const TAIPY_CLIENT_ID = "TaipyClientId";
 
-export type WsMessageType = "A" | "U" | "DU" | "MU" | "RU" | "AL" | "BL" | "NA" | "ID" | "MS" | "DF" | "PR" | "ACK" | "GMC" | "GVS";
+export type WsMessageType =
+    | "A"
+    | "U"
+    | "DU"
+    | "MU"
+    | "RU"
+    | "AL"
+    | "BL"
+    | "NA"
+    | "ID"
+    | "MS"
+    | "DF"
+    | "PR"
+    | "ACK"
+    | "GMC"
+    | "GVS"
+    | "AID";
 
 export interface WsMessage {
     type: WsMessageType;
diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py
index 3378170d7c..a16bf3ceea 100644
--- a/taipy/gui/gui.py
+++ b/taipy/gui/gui.py
@@ -618,6 +618,8 @@ def _manage_message(self, msg_type: _WsType, message: dict) -> None:
                         self.__handle_ws_get_module_context(payload)
                     elif msg_type == _WsType.GET_VARIABLES.value:
                         self.__handle_ws_get_variables()
+                    elif msg_type == _WsType.APP_ID.value:
+                        self.__handle_ws_app_id(message)
                 self.__send_ack(message.get("ack_id"))
         except Exception as e:  # pragma: no cover
             _warn(f"Decoding Message has failed: {message}", e)
@@ -1092,6 +1094,21 @@ def __handle_ws_get_variables(self):
             }
         )
 
+    def __handle_ws_app_id(self, message: t.Any):
+        if not isinstance(message, dict):
+            return
+        name = message.get("name", "")
+        payload = message.get("payload", "")
+        app_id = id(self)
+        if payload == app_id:
+            return
+        self.__send_ws(
+            {
+                "type": _WsType.APP_ID.value,
+                "payload": {"name": name, "id": app_id},
+            }
+        )
+
     def __send_ws(self, payload: dict, allow_grouping=True) -> None:
         grouping_message = self.__get_message_grouping() if allow_grouping else None
         if grouping_message is None:
diff --git a/taipy/gui/gui_types.py b/taipy/gui/gui_types.py
index ccc161e09e..6c717d95e0 100644
--- a/taipy/gui/gui_types.py
+++ b/taipy/gui/gui_types.py
@@ -42,6 +42,7 @@ class _WsType(Enum):
     BLOCK = "BL"
     NAVIGATE = "NA"
     CLIENT_ID = "ID"
+    APP_ID = "AID"
     MULTIPLE_MESSAGE = "MS"
     DOWNLOAD_FILE = "DF"
     PARTIAL = "PR"


Code review comments:
diff_hunk: @@ -22,10 +22,30 @@ export class VariableManager {
     constructor(variableModuleData: VariableModuleData) {
         this._data = {};
         this._variables = {};
-        this.resetInitData(variableModuleData);
+        this.init(variableModuleData);
     }
 
-    resetInitData(variableModuleData: VariableModuleData) {
+    init(variableModuleData: VariableModuleData) {
+        // Identify changes between the new and old data
+        const changes: VariableModuleData = {};
+        for (const context in this._variables) {
+            if (!(context in variableModuleData)) {
+                changes[context] = this._variables[context];
+                continue;
+            }
+            for (const variable in this._variables[context]) {
+                if (!(variable in variableModuleData[context])) {
+                    if (!(context in changes)) {
+                        changes[context] = {};
+                    }
+                    changes[context][variable] = this._variables[context][variable];
+                }
+            }
+        }
+        if (changes) {
path: frontend/taipy-gui/base/src/variableManager.ts
text: @user1:
Changes will always be truthy ({} == true)

@author:
Opps my mistake... Thanks for spotting it out

diff_hunk: @@ -32,41 +33,59 @@ export const initSocket = (socket: Socket, appManager: TaipyApp) => {
     });
     // handle message data from backend
     socket.on("message", (message: WsMessage) => {
-        processIncomingMessage(message, appManager);
+        processWsMessage(message, appManager);
     });
     // only now does the socket tries to open/connect
     if (!socket.connected) {
         socket.connect();
     }
 };
 
-export const processIncomingMessage = (message: WsMessage, appManager: TaipyApp) => {
+const processWsMessage = (message: WsMessage, appManager: TaipyApp) => {
     if (message.type) {
         if (message.type === "MU" && Array.isArray(message.payload)) {
             for (const muPayload of message.payload as [MultipleUpdatePayload]) {
                 const encodedName = muPayload.name;
-                const value = muPayload.payload.value;
+                const { value } = muPayload.payload;
                 appManager.variableManager?.update(encodedName, value);
                 appManager.onChange && appManager.onChange(appManager, encodedName, value);
             }
         } else if (message.type === "ID") {
-            const id = (message as unknown as IdMessage).id;
+            const { id } = message as unknown as IdMessage;
             storeClientId(id);
             appManager.clientId = id;
             appManager.updateContext(appManager.path);
         } else if (message.type === "GMC") {
             const mc = (message.payload as Record<string, unknown>).data as string;
             window.localStorage.setItem("ModuleContext", mc);
             appManager.context = mc;
-            sendWsMessage(appManager.socket, "GVS", "get_variables", {}, appManager.clientId, appManager.context);
         } else if (message.type === "GVS") {
             const variableData = (message.payload as Record<string, unknown>).data as VariableModuleData;
             if (appManager.variableManager) {
-                appManager.variableManager.resetInitData(variableData);
+                appManager.variableManager.init(variableData);
             } else {
                 appManager.variableManager = new VariableManager(variableData);
                 appManager.onInit && appManager.onInit(appManager);
             }
+        } else if (message.type === "AID") {
+            const payload = message.payload as Record<string, unknown>;
+            if (payload.name === "reconnect") {
+                return appManager.init();
+            }
+            appManager.appId = payload.id as string;
         }
+        postWsMessageProcessing(message, appManager);
+    }
+};
+
+const postWsMessageProcessing = (message: WsMessage, appManager: TaipyApp) => {
+    // perform data population only when all necessary metadata is ready
+    if (
+        ["ID", "AID", "GMC"].includes(message.type) &&
path: frontend/taipy-gui/base/src/utils.ts
text: @user1:
make the array const ?

@author:
will do!